From 337b1189ec3d71a25a60a4df04eb3575826054a6 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Thu, 20 Jun 2024 13:09:19 +0100 Subject: [PATCH 01/83] Fix race condition which prevented pagination if the initial page load in a table encountered the internal row join limit --- .../src/components/grid/stores/pagination.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/frontend-core/src/components/grid/stores/pagination.js b/packages/frontend-core/src/components/grid/stores/pagination.js index 1dbea6e0d2..f4fdee2ff4 100644 --- a/packages/frontend-core/src/components/grid/stores/pagination.js +++ b/packages/frontend-core/src/components/grid/stores/pagination.js @@ -15,8 +15,14 @@ export const initialise = context => { ) // Fetch next page when fewer than 25 remaining rows to scroll - remainingRows.subscribe(remaining => { - if (remaining < 25 && get(rowCount)) { + const needsNewPage = derived( + [remainingRows, rowCount], + ([$remainingRows, $rowCount]) => { + return $remainingRows < 25 && $rowCount + } + ) + needsNewPage.subscribe($needsNewPage => { + if ($needsNewPage) { rows.actions.loadNextPage() } }) From 0f3decd127f3b20bbad6e85f4e0db2a6ace5d4ff Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Thu, 20 Jun 2024 13:12:59 +0100 Subject: [PATCH 02/83] Prevent unnecessary store updates --- .../src/components/grid/stores/pagination.js | 2 +- .../frontend-core/src/components/grid/stores/rows.js | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/frontend-core/src/components/grid/stores/pagination.js b/packages/frontend-core/src/components/grid/stores/pagination.js index f4fdee2ff4..bcf10ee3df 100644 --- a/packages/frontend-core/src/components/grid/stores/pagination.js +++ b/packages/frontend-core/src/components/grid/stores/pagination.js @@ -1,4 +1,4 @@ -import { derived, get } from "svelte/store" +import { derived } from "svelte/store" export const initialise = context => { const { scrolledRowCount, rows, visualRowCapacity } = context diff --git a/packages/frontend-core/src/components/grid/stores/rows.js b/packages/frontend-core/src/components/grid/stores/rows.js index d354dfd9b3..e4b69819ac 100644 --- a/packages/frontend-core/src/components/grid/stores/rows.js +++ b/packages/frontend-core/src/components/grid/stores/rows.js @@ -569,10 +569,12 @@ export const initialise = context => { // Wipe the row change cache when changing row previousFocusedRowId.subscribe(id => { if (id && !get(inProgressChanges)[id]) { - rowChangeCache.update(state => { - delete state[id] - return state - }) + if (Object.keys(get(rowChangeCache)[id] || {}).length) { + rowChangeCache.update(state => { + delete state[id] + return state + }) + } } }) From 93e222036e9dec203bb476c70693e4f5252eda9e Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Thu, 20 Jun 2024 14:04:32 +0100 Subject: [PATCH 03/83] Make cell selection and row selection mutually exclusive --- .../grid/overlays/KeyboardManager.svelte | 6 -- .../src/components/grid/stores/ui.js | 63 +++++++++++-------- 2 files changed, 37 insertions(+), 32 deletions(-) diff --git a/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte b/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte index ced78c0fe0..f38eb8d997 100644 --- a/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte +++ b/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte @@ -134,12 +134,6 @@ case "Enter": focusCell() break - case " ": - case "Space": - if ($config.canDeleteRows) { - toggleSelectRow() - } - break default: startEnteringValue(e.key, e.which) } diff --git a/packages/frontend-core/src/components/grid/stores/ui.js b/packages/frontend-core/src/components/grid/stores/ui.js index 6f7541e642..7b7dc1dee9 100644 --- a/packages/frontend-core/src/components/grid/stores/ui.js +++ b/packages/frontend-core/src/components/grid/stores/ui.js @@ -32,20 +32,6 @@ export const createStores = context => { null ) - // Toggles whether a certain row ID is selected or not - const toggleSelectedRow = id => { - selectedRows.update(state => { - let newState = { - ...state, - [id]: !state[id], - } - if (!newState[id]) { - delete newState[id] - } - return newState - }) - } - return { focusedCellId, focusedCellAPI, @@ -58,12 +44,7 @@ export const createStores = context => { keyboardBlocked, isDragging, buttonColumnWidth, - selectedRows: { - ...selectedRows, - actions: { - toggleRow: toggleSelectedRow, - }, - }, + selectedRows, } } @@ -112,7 +93,7 @@ export const deriveStores = context => { } export const createActions = context => { - const { focusedCellId, hoveredRowId } = context + const { focusedCellId, hoveredRowId, selectedRows } = context // Callback when leaving the grid, deselecting all focussed or selected items const blur = () => { @@ -120,12 +101,32 @@ export const createActions = context => { hoveredRowId.set(null) } + // Toggles whether a certain row ID is selected or not + const toggleSelectedRow = id => { + selectedRows.update(state => { + let newState = { + ...state, + [id]: !state[id], + } + if (!newState[id]) { + delete newState[id] + } + return newState + }) + } + return { ui: { actions: { blur, }, }, + selectedRows: { + ...selectedRows, + actions: { + toggleRow: toggleSelectedRow, + }, + }, } } @@ -186,18 +187,21 @@ export const initialise = context => { lastFocusedRowId = id }) - // Remember the last focused cell ID so that we can store the previous one let lastFocusedCellId = null focusedCellId.subscribe(id => { + // Remember the last focused cell ID so that we can store the previous one previousFocusedCellId.set(lastFocusedCellId) lastFocusedCellId = id - }) - // Remove hovered row when a cell is selected - focusedCellId.subscribe(cell => { - if (cell && get(hoveredRowId)) { + // Remove hovered row when a cell is selected + if (id && get(hoveredRowId)) { hoveredRowId.set(null) } + + // Clear row selection when focusing a cell + if (id && Object.keys(get(selectedRows)).length) { + selectedRows.set({}) + } }) // Pull row height from table as long as we don't have a fixed height @@ -215,4 +219,11 @@ export const initialise = context => { rowHeight.set(get(definition)?.rowHeight || DefaultRowHeight) } }) + + // Clear focused cell when selecting rows + selectedRows.subscribe(rows => { + if (get(focusedCellId) && Object.keys(rows).length) { + focusedCellId.set(null) + } + }) } From 42f781bb760bcb7dfbf22fd2541b114192267ec0 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Thu, 20 Jun 2024 14:36:22 +0100 Subject: [PATCH 04/83] Add support for bulk selecting rows with shift click --- .../components/grid/cells/GutterCell.svelte | 10 +- .../src/components/grid/stores/ui.js | 94 ++++++++++++++----- 2 files changed, 79 insertions(+), 25 deletions(-) diff --git a/packages/frontend-core/src/components/grid/cells/GutterCell.svelte b/packages/frontend-core/src/components/grid/cells/GutterCell.svelte index 60b41a2b87..4393d0febc 100644 --- a/packages/frontend-core/src/components/grid/cells/GutterCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/GutterCell.svelte @@ -23,7 +23,15 @@ svelteDispatch("select") const id = row?._id if (id) { - selectedRows.actions.toggleRow(id) + if (e.shiftKey) { + if (rowSelected) { + e.preventDefault() + } else { + selectedRows.actions.bulkSelectRows(id) + } + } else { + selectedRows.actions.toggleRow(id) + } } } diff --git a/packages/frontend-core/src/components/grid/stores/ui.js b/packages/frontend-core/src/components/grid/stores/ui.js index 7b7dc1dee9..37cdf67007 100644 --- a/packages/frontend-core/src/components/grid/stores/ui.js +++ b/packages/frontend-core/src/components/grid/stores/ui.js @@ -23,19 +23,9 @@ export const createStores = context => { const isDragging = writable(false) const buttonColumnWidth = writable(0) - // Derive the current focused row ID - const focusedRowId = derived( - focusedCellId, - $focusedCellId => { - return parseCellID($focusedCellId)?.id - }, - null - ) - return { focusedCellId, focusedCellAPI, - focusedRowId, previousFocusedRowId, previousFocusedCellId, hoveredRowId, @@ -49,25 +39,34 @@ export const createStores = context => { } export const deriveStores = context => { - const { focusedCellId, rows, rowLookupMap, rowHeight, stickyColumn, width } = - context + const { + focusedCellId, + rows, + rowLookupMap, + rowHeight, + stickyColumn, + width, + selectedRows, + } = context + + // Derive the current focused row ID + const focusedRowId = derived(focusedCellId, $focusedCellId => { + return parseCellID($focusedCellId)?.id + }) // Derive the row that contains the selected cell const focusedRow = derived( - [focusedCellId, rowLookupMap, rows], - ([$focusedCellId, $rowLookupMap, $rows]) => { - const rowId = parseCellID($focusedCellId)?.id - + [focusedRowId, rowLookupMap, rows], + ([$focusedRowId, $rowLookupMap, $rows]) => { // Edge case for new rows - if (rowId === NewRowID) { + if ($focusedRowId === NewRowID) { return { _id: NewRowID } } // All normal rows - const index = $rowLookupMap[rowId] + const index = $rowLookupMap[$focusedRowId] return $rows[index] - }, - null + } ) // Derive the amount of content lines to show in cells depending on row height @@ -85,15 +84,31 @@ export const deriveStores = context => { return ($stickyColumn?.width || 0) + $width + GutterWidth < 800 }) + // Derive we have any selected rows or not + const hasSelectedRows = derived(selectedRows, $selectedRows => { + return Object.keys($selectedRows).length + }) + return { + focusedRowId, focusedRow, contentLines, compact, + hasSelectedRows, } } export const createActions = context => { - const { focusedCellId, hoveredRowId, selectedRows } = context + const { + focusedCellId, + hoveredRowId, + selectedRows, + rowLookupMap, + rows, + hasSelectedRows, + } = context + // Keep the last selected index to use with bulk selection + let lastSelectedIndex = null // Callback when leaving the grid, deselecting all focussed or selected items const blur = () => { @@ -110,11 +125,40 @@ export const createActions = context => { } if (!newState[id]) { delete newState[id] + } else { + lastSelectedIndex = get(rowLookupMap)[id] } return newState }) } + const bulkSelectRows = id => { + if (!get(hasSelectedRows)) { + toggleSelectedRow(id) + return + } + // There should always be a last selected index + if (lastSelectedIndex == null) { + throw "NO LAST SELECTED INDEX" + } + const thisIndex = get(rowLookupMap)[id] + + // Skip if indices are the same + if (lastSelectedIndex === thisIndex) { + return + } + + const from = Math.min(lastSelectedIndex, thisIndex) + const to = Math.max(lastSelectedIndex, thisIndex) + const $rows = get(rows) + selectedRows.update(state => { + for (let i = from; i <= to; i++) { + state[$rows[i]._id] = true + } + return state + }) + } + return { ui: { actions: { @@ -125,6 +169,7 @@ export const createActions = context => { ...selectedRows, actions: { toggleRow: toggleSelectedRow, + bulkSelectRows, }, }, } @@ -142,6 +187,7 @@ export const initialise = context => { definition, rowHeight, fixedRowHeight, + hasSelectedRows, } = context // Ensure we clear invalid rows from state if they disappear @@ -199,7 +245,7 @@ export const initialise = context => { } // Clear row selection when focusing a cell - if (id && Object.keys(get(selectedRows)).length) { + if (id && get(hasSelectedRows)) { selectedRows.set({}) } }) @@ -221,8 +267,8 @@ export const initialise = context => { }) // Clear focused cell when selecting rows - selectedRows.subscribe(rows => { - if (get(focusedCellId) && Object.keys(rows).length) { + hasSelectedRows.subscribe(selected => { + if (get(focusedCellId) && selected) { focusedCellId.set(null) } }) From 2dbf9a5118eb297b90f76b57b824cbe92fac2769 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Thu, 20 Jun 2024 14:37:05 +0100 Subject: [PATCH 05/83] Add comments --- .../frontend-core/src/components/grid/cells/GutterCell.svelte | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/frontend-core/src/components/grid/cells/GutterCell.svelte b/packages/frontend-core/src/components/grid/cells/GutterCell.svelte index 4393d0febc..b5cf7eb5d6 100644 --- a/packages/frontend-core/src/components/grid/cells/GutterCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/GutterCell.svelte @@ -23,7 +23,9 @@ svelteDispatch("select") const id = row?._id if (id) { + // Bulk select with shift if (e.shiftKey) { + // Prevent default if already selected, to prevent checkbox clearing if (rowSelected) { e.preventDefault() } else { From f86c80af3235668a04690f6b1d1c9d895e9778b4 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Thu, 20 Jun 2024 15:02:44 +0100 Subject: [PATCH 06/83] Add bulk deletion to multi row context menu --- .../grid/controls/BulkDeleteHandler.svelte | 6 +- .../grid/overlays/MenuOverlay.svelte | 132 +++++++++++------- .../src/components/grid/stores/menu.js | 23 ++- .../src/components/grid/stores/ui.js | 16 +-- 4 files changed, 112 insertions(+), 65 deletions(-) diff --git a/packages/frontend-core/src/components/grid/controls/BulkDeleteHandler.svelte b/packages/frontend-core/src/components/grid/controls/BulkDeleteHandler.svelte index cb90f12293..1f835c3991 100644 --- a/packages/frontend-core/src/components/grid/controls/BulkDeleteHandler.svelte +++ b/packages/frontend-core/src/components/grid/controls/BulkDeleteHandler.svelte @@ -2,7 +2,8 @@ import { Modal, ModalContent } from "@budibase/bbui" import { getContext, onMount } from "svelte" - const { selectedRows, rows, subscribe, notifications } = getContext("grid") + const { selectedRows, rows, subscribe, notifications, menu } = + getContext("grid") let modal @@ -16,6 +17,9 @@ const count = rowsToDelete.length await rows.actions.deleteRows(rowsToDelete) $notifications.success(`Deleted ${count} row${count === 1 ? "" : "s"}`) + + // Ensure menu is closed, as we may have triggered this from there + menu.actions.close() } onMount(() => subscribe("request-bulk-delete", () => modal?.show())) diff --git a/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte b/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte index 4a413d7dde..db6e82293d 100644 --- a/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte +++ b/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte @@ -20,6 +20,7 @@ focusedRowId, notifications, hasBudibaseIdentifiers, + selectedRowCount, } = getContext("grid") let anchor @@ -37,6 +38,10 @@ $notifications.success("Deleted 1 row") } + const bulkDelete = () => { + dispatch("request-bulk-delete") + } + const duplicate = async () => { menu.actions.close() const newRow = await rows.actions.duplicateRow($focusedRow) @@ -58,59 +63,80 @@ {#key style} - - Copy - - - Paste - - dispatch("edit-row", $focusedRow)} - on:click={menu.actions.close} - > - Edit row in modal - - copyToClipboard($focusedRow?._id)} - on:click={menu.actions.close} - > - Copy row _id - - copyToClipboard($focusedRow?._rev)} - on:click={menu.actions.close} - > - Copy row _rev - - - Duplicate row - - - Delete row - + {#if $menu.multiRowMode} + + Duplicate {$selectedRowCount} rows + + + Delete {$selectedRowCount} rows + + {:else} + + Copy + + + Paste + + dispatch("edit-row", $focusedRow)} + on:click={menu.actions.close} + > + Edit row in modal + + copyToClipboard($focusedRow?._id)} + on:click={menu.actions.close} + > + Copy row _id + + copyToClipboard($focusedRow?._rev)} + on:click={menu.actions.close} + > + Copy row _rev + + + Duplicate row + + + Delete row + + {/if} {/key} diff --git a/packages/frontend-core/src/components/grid/stores/menu.js b/packages/frontend-core/src/components/grid/stores/menu.js index d7f0e21b84..9120ad4a9f 100644 --- a/packages/frontend-core/src/components/grid/stores/menu.js +++ b/packages/frontend-core/src/components/grid/stores/menu.js @@ -1,4 +1,5 @@ -import { writable } from "svelte/store" +import { writable, get } from "svelte/store" +import { parseCellID } from "../lib/utils" export const createStores = () => { const menu = writable({ @@ -13,7 +14,8 @@ export const createStores = () => { } export const createActions = context => { - const { menu, focusedCellId, gridID } = context + const { menu, focusedCellId, gridID, selectedRows, selectedRowCount } = + context const open = (cellId, e) => { e.preventDefault() @@ -29,11 +31,26 @@ export const createActions = context => { // Compute bounds of cell relative to outer data node const targetBounds = e.target.getBoundingClientRect() const dataBounds = dataNode.getBoundingClientRect() - focusedCellId.set(cellId) + + // Check if there are multiple rows selected, and this is one of them + let multiRowMode = false + if (get(selectedRowCount) > 1) { + const rowId = parseCellID(cellId).id + if (get(selectedRows)[rowId]) { + multiRowMode = true + } + } + + // Only focus this cell if not in multi row mode + if (!multiRowMode) { + focusedCellId.set(cellId) + } + menu.set({ left: targetBounds.left - dataBounds.left + e.offsetX, top: targetBounds.top - dataBounds.top + e.offsetY, visible: true, + multiRowMode, }) } diff --git a/packages/frontend-core/src/components/grid/stores/ui.js b/packages/frontend-core/src/components/grid/stores/ui.js index 37cdf67007..6280b05eca 100644 --- a/packages/frontend-core/src/components/grid/stores/ui.js +++ b/packages/frontend-core/src/components/grid/stores/ui.js @@ -85,7 +85,7 @@ export const deriveStores = context => { }) // Derive we have any selected rows or not - const hasSelectedRows = derived(selectedRows, $selectedRows => { + const selectedRowCount = derived(selectedRows, $selectedRows => { return Object.keys($selectedRows).length }) @@ -94,7 +94,7 @@ export const deriveStores = context => { focusedRow, contentLines, compact, - hasSelectedRows, + selectedRowCount, } } @@ -105,7 +105,7 @@ export const createActions = context => { selectedRows, rowLookupMap, rows, - hasSelectedRows, + selectedRowCount, } = context // Keep the last selected index to use with bulk selection let lastSelectedIndex = null @@ -133,7 +133,7 @@ export const createActions = context => { } const bulkSelectRows = id => { - if (!get(hasSelectedRows)) { + if (!get(selectedRowCount)) { toggleSelectedRow(id) return } @@ -187,7 +187,7 @@ export const initialise = context => { definition, rowHeight, fixedRowHeight, - hasSelectedRows, + selectedRowCount, } = context // Ensure we clear invalid rows from state if they disappear @@ -245,7 +245,7 @@ export const initialise = context => { } // Clear row selection when focusing a cell - if (id && get(hasSelectedRows)) { + if (id && get(selectedRowCount)) { selectedRows.set({}) } }) @@ -267,8 +267,8 @@ export const initialise = context => { }) // Clear focused cell when selecting rows - hasSelectedRows.subscribe(selected => { - if (get(focusedCellId) && selected) { + selectedRowCount.subscribe(count => { + if (get(focusedCellId) && count) { focusedCellId.set(null) } }) From 64cc3efc2ae90b689d69406138a16d2fd15352ea Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Fri, 21 Jun 2024 08:08:19 +0100 Subject: [PATCH 07/83] Add bulk row duplication to tables using throttled save row calls --- .../src/components/grid/layout/NewRow.svelte | 5 +- .../grid/overlays/MenuOverlay.svelte | 17 ++++- .../grid/stores/datasources/table.js | 5 +- .../grid/stores/datasources/viewV2.js | 7 +- .../src/components/grid/stores/rows.js | 74 ++++++++++++++++--- packages/frontend-core/src/utils/utils.js | 2 + 6 files changed, 95 insertions(+), 15 deletions(-) diff --git a/packages/frontend-core/src/components/grid/layout/NewRow.svelte b/packages/frontend-core/src/components/grid/layout/NewRow.svelte index 68ace8a5b2..f7fd2c661f 100644 --- a/packages/frontend-core/src/components/grid/layout/NewRow.svelte +++ b/packages/frontend-core/src/components/grid/layout/NewRow.svelte @@ -54,7 +54,10 @@ const newRowIndex = offset ? undefined : 0 let rowToCreate = { ...newRow } delete rowToCreate._isNewRow - const savedRow = await rows.actions.addRow(rowToCreate, newRowIndex) + const savedRow = await rows.actions.addRow({ + row: rowToCreate, + idx: newRowIndex, + }) if (savedRow) { // Reset state clear() diff --git a/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte b/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte index db6e82293d..10bd974260 100644 --- a/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte +++ b/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte @@ -21,6 +21,7 @@ notifications, hasBudibaseIdentifiers, selectedRowCount, + selectedRows, } = getContext("grid") let anchor @@ -51,6 +52,18 @@ } } + const bulkDuplicate = async () => { + menu.actions.close() + const rowsToDuplicate = Object.keys($selectedRows).map(id => { + return rows.actions.getRow(id) + }) + const newRows = await rows.actions.bulkDuplicate(rowsToDuplicate) + if (newRows[0]) { + const column = $stickyColumn?.name || $columns[0].name + $focusedCellId = getCellID(newRows[0]._id, column) + } + } + const copyToClipboard = async value => { await Helpers.copyToClipboard(value) $notifications.success("Copied to clipboard") @@ -66,8 +79,8 @@ {#if $menu.multiRowMode} Duplicate {$selectedRowCount} rows diff --git a/packages/frontend-core/src/components/grid/stores/datasources/table.js b/packages/frontend-core/src/components/grid/stores/datasources/table.js index 847dfd2c6b..4f7f6a29f7 100644 --- a/packages/frontend-core/src/components/grid/stores/datasources/table.js +++ b/packages/frontend-core/src/components/grid/stores/datasources/table.js @@ -10,7 +10,10 @@ export const createActions = context => { } const saveRow = async row => { - row.tableId = get(datasource)?.tableId + row = { + ...row, + tableId: get(datasource)?.tableId, + } return await API.saveRow(row, SuppressErrors) } diff --git a/packages/frontend-core/src/components/grid/stores/datasources/viewV2.js b/packages/frontend-core/src/components/grid/stores/datasources/viewV2.js index ed31d0ae44..2a1ccddc43 100644 --- a/packages/frontend-core/src/components/grid/stores/datasources/viewV2.js +++ b/packages/frontend-core/src/components/grid/stores/datasources/viewV2.js @@ -11,8 +11,11 @@ export const createActions = context => { const saveRow = async row => { const $datasource = get(datasource) - row.tableId = $datasource?.tableId - row._viewId = $datasource?.id + row = { + ...row, + tableId: $datasource?.tableId, + _viewId: $datasource?.id, + } return { ...(await API.saveRow(row, SuppressErrors)), _viewId: row._viewId, diff --git a/packages/frontend-core/src/components/grid/stores/rows.js b/packages/frontend-core/src/components/grid/stores/rows.js index e4b69819ac..f23ce1e1b3 100644 --- a/packages/frontend-core/src/components/grid/stores/rows.js +++ b/packages/frontend-core/src/components/grid/stores/rows.js @@ -4,6 +4,7 @@ import { NewRowID, RowPageSize } from "../lib/constants" import { getCellID, parseCellID } from "../lib/utils" import { tick } from "svelte" import { Helpers } from "@budibase/bbui" +import { sleep } from "../../../utils/utils" export const createStores = () => { const rows = writable([]) @@ -274,11 +275,9 @@ export const createActions = context => { } // Adds a new row - const addRow = async (row, idx, bubble = false) => { + const addRow = async ({ row, idx, bubble = false, notify = true }) => { try { - // Create row. Spread row so we can mutate and enrich safely. - let newRow = { ...row } - newRow = await datasource.actions.addRow(newRow) + const newRow = await datasource.actions.addRow(row) // Update state if (idx != null) { @@ -291,8 +290,9 @@ export const createActions = context => { handleNewRows([newRow]) } - // Refresh row to ensure data is in the correct format - get(notifications).success("Row created successfully") + if (notify) { + get(notifications).success("Row created successfully") + } return newRow } catch (error) { if (bubble) { @@ -305,17 +305,72 @@ export const createActions = context => { // Duplicates a row, inserting the duplicate row after the existing one const duplicateRow = async row => { - let clone = { ...row } + let clone = cleanRow(row) delete clone._id delete clone._rev - delete clone.__idx try { - return await addRow(clone, row.__idx + 1, true) + const duped = await addRow({ + row: clone, + idx: row.__idx + 1, + bubble: true, + notify: false, + }) + get(notifications).success("Duplicated 1 row") + return duped } catch (error) { handleValidationError(row._id, error) } } + // Duplicates multiple rows, inserting them after the last source row + const bulkDuplicate = async rowsToDupe => { + // Find index of last row + const $rowLookupMap = get(rowLookupMap) + const index = Math.max(...rowsToDupe.map(row => $rowLookupMap[row._id])) + + // Clone and clean rows + const clones = rowsToDupe.map(row => { + let clone = cleanRow(row) + delete clone._id + delete clone._rev + return clone + }) + + // Create rows + let saved = [] + let failed = 0 + for (let clone of clones) { + try { + saved.push(await datasource.actions.addRow(clone)) + rowCacheMap[saved._id] = true + await sleep(50) // Small sleep to ensure we avoid rate limiting + } catch (error) { + failed++ + console.error("Duplicating row failed", error) + } + } + + // Add to state + if (saved.length) { + rows.update(state => { + return state.toSpliced(index + 1, 0, ...saved) + }) + } + + // Notify user + if (saved.length) { + get(notifications).success( + `Duplicated ${saved.length} row${saved.length === 1 ? "" : "s"}` + ) + } + if (failed) { + get(notifications).error( + `Failed to duplicate ${failed} row${failed === 1 ? "" : "s"}` + ) + } + return saved + } + // Replaces a row in state with the newly defined row, handling updates, // addition and deletion const replaceRow = (id, row) => { @@ -541,6 +596,7 @@ export const createActions = context => { actions: { addRow, duplicateRow, + bulkDuplicate, getRow, updateValue, applyRowChanges, diff --git a/packages/frontend-core/src/utils/utils.js b/packages/frontend-core/src/utils/utils.js index 1bee3d6c04..b8cfa5ad37 100644 --- a/packages/frontend-core/src/utils/utils.js +++ b/packages/frontend-core/src/utils/utils.js @@ -1,6 +1,8 @@ import { makePropSafe as safe } from "@budibase/string-templates" import { Helpers } from "@budibase/bbui" +export const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) + /** * Utility to wrap an async function and ensure all invocations happen * sequentially. From 8a8d835a1aa8a3634fb8f008059e520fd514bb4e Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Fri, 21 Jun 2024 08:25:02 +0100 Subject: [PATCH 08/83] Add confirmation before bulk duplicating rows and loading spinner --- .../controls/BulkDuplicationHandler.svelte | 53 +++++++++++++++++++ .../src/components/grid/layout/Grid.svelte | 4 ++ .../grid/overlays/MenuOverlay.svelte | 14 ++--- 3 files changed, 60 insertions(+), 11 deletions(-) create mode 100644 packages/frontend-core/src/components/grid/controls/BulkDuplicationHandler.svelte diff --git a/packages/frontend-core/src/components/grid/controls/BulkDuplicationHandler.svelte b/packages/frontend-core/src/components/grid/controls/BulkDuplicationHandler.svelte new file mode 100644 index 0000000000..bc321fffde --- /dev/null +++ b/packages/frontend-core/src/components/grid/controls/BulkDuplicationHandler.svelte @@ -0,0 +1,53 @@ + + + + + Are you sure you want to duplicate {$selectedRowCount} + row{$selectedRowCount === 1 ? "" : "s"}? + {#if $selectedRowCount >= 10} +

+ This may take a few seconds. + {/if} +
+
diff --git a/packages/frontend-core/src/components/grid/layout/Grid.svelte b/packages/frontend-core/src/components/grid/layout/Grid.svelte index 8a82209162..8496aebd35 100644 --- a/packages/frontend-core/src/components/grid/layout/Grid.svelte +++ b/packages/frontend-core/src/components/grid/layout/Grid.svelte @@ -7,6 +7,7 @@ import { createAPIClient } from "../../../api" import { attachStores } from "../stores" import BulkDeleteHandler from "../controls/BulkDeleteHandler.svelte" + import BulkDuplicationHandler from "../controls/BulkDuplicationHandler.svelte" import GridBody from "./GridBody.svelte" import ResizeOverlay from "../overlays/ResizeOverlay.svelte" import ReorderOverlay from "../overlays/ReorderOverlay.svelte" @@ -212,6 +213,9 @@ {#if $config.canDeleteRows} {/if} + {#if $config.canAddRows} + + {/if} diff --git a/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte b/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte index 10bd974260..5535538cf0 100644 --- a/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte +++ b/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte @@ -52,16 +52,8 @@ } } - const bulkDuplicate = async () => { - menu.actions.close() - const rowsToDuplicate = Object.keys($selectedRows).map(id => { - return rows.actions.getRow(id) - }) - const newRows = await rows.actions.bulkDuplicate(rowsToDuplicate) - if (newRows[0]) { - const column = $stickyColumn?.name || $columns[0].name - $focusedCellId = getCellID(newRows[0]._id, column) - } + const bulkDuplicate = () => { + dispatch("request-bulk-duplicate") } const copyToClipboard = async value => { @@ -79,7 +71,7 @@ {#if $menu.multiRowMode} 50} on:click={bulkDuplicate} > Duplicate {$selectedRowCount} rows From 734991057253c64354e38c2e7d6a19b8c904b741 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Fri, 21 Jun 2024 11:08:24 +0100 Subject: [PATCH 09/83] Add support for selecting multiple cells --- packages/bbui/src/bbui.css | 22 +-- .../src/components/grid/cells/DataCell.svelte | 42 +++++- .../src/components/grid/cells/GridCell.svelte | 2 + .../src/components/grid/layout/GridRow.svelte | 10 +- .../grid/layout/StickyColumn.svelte | 11 +- .../grid/overlays/MenuOverlay.svelte | 20 ++- .../src/components/grid/stores/columns.js | 12 +- .../src/components/grid/stores/index.js | 2 + .../src/components/grid/stores/menu.js | 31 ++++- .../src/components/grid/stores/selection.js | 130 ++++++++++++++++++ .../src/components/grid/stores/ui.js | 20 ++- .../frontend-core/src/themes/midnight.css | 34 ++--- packages/frontend-core/src/themes/nord.css | 2 +- 13 files changed, 289 insertions(+), 49 deletions(-) create mode 100644 packages/frontend-core/src/components/grid/stores/selection.js diff --git a/packages/bbui/src/bbui.css b/packages/bbui/src/bbui.css index 9b5d89f61c..d60a3e18ea 100644 --- a/packages/bbui/src/bbui.css +++ b/packages/bbui/src/bbui.css @@ -3,13 +3,13 @@ --ink: #000000; /* Brand colours */ - --bb-coral: #FF4E4E; - --bb-coral-light: #F97777; - --bb-indigo: #6E56FF; - --bb-indigo-light: #9F8FFF; - --bb-lime: #ECFFB5; + --bb-coral: #ff4e4e; + --bb-coral-light: #f97777; + --bb-indigo: #6e56ff; + --bb-indigo-light: #9f8fff; + --bb-lime: #ecffb5; --bb-forest-green: #053835; - --bb-beige: #F6EFEA; + --bb-beige: #f6efea; --grey-1: #fafafa; --grey-2: #f5f5f5; @@ -49,10 +49,10 @@ --rounded-medium: 8px; --rounded-large: 16px; - --font-sans: "Source Sans Pro", -apple-system, BlinkMacSystemFont, Segoe UI, "Inter", - "Helvetica Neue", Arial, "Noto Sans", sans-serif; - --font-accent: "Source Sans Pro", -apple-system, BlinkMacSystemFont, Segoe UI, "Inter", - "Helvetica Neue", Arial, "Noto Sans", sans-serif; + --font-sans: "Source Sans Pro", -apple-system, BlinkMacSystemFont, Segoe UI, + "Inter", "Helvetica Neue", Arial, "Noto Sans", sans-serif; + --font-accent: "Source Sans Pro", -apple-system, BlinkMacSystemFont, Segoe UI, + "Inter", "Helvetica Neue", Arial, "Noto Sans", sans-serif; --font-serif: "Georgia", Cambria, Times New Roman, Times, serif; --font-mono: Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; @@ -111,7 +111,7 @@ a { /* Custom theme additions */ .spectrum--darkest { --drop-shadow: rgba(0, 0, 0, 0.6); - --spectrum-global-color-blue-100: rgb(28, 33, 43); + --spectrum-global-color-blue-100: rgb(30, 36, 50); } .spectrum--dark { --drop-shadow: rgba(0, 0, 0, 0.3); diff --git a/packages/frontend-core/src/components/grid/cells/DataCell.svelte b/packages/frontend-core/src/components/grid/cells/DataCell.svelte index 30d7e39921..d0ad26e939 100644 --- a/packages/frontend-core/src/components/grid/cells/DataCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/DataCell.svelte @@ -4,12 +4,19 @@ import { getCellRenderer } from "../lib/renderers" import { derived, writable } from "svelte/store" - const { rows, focusedCellId, focusedCellAPI, menu, config, validation } = - getContext("grid") + const { + rows, + focusedCellId, + focusedCellAPI, + menu, + config, + validation, + cellSelection, + } = getContext("grid") export let highlighted - export let selected export let rowFocused + export let rowSelected export let rowIdx export let topRow = false export let focused @@ -20,6 +27,8 @@ export let updateValue = rows.actions.updateValue export let contentLines = 1 export let hidden = false + export let isSelectingCells = false + export let selectedCells = {} const emptyError = writable(null) @@ -43,6 +52,11 @@ } } + // Callbacks for cell selection + $: cellSelected = selectedCells[cellId] + $: updateSelectionCallback = isSelectingCells ? updateSelection : null + $: stopSelectionCallback = isSelectingCells ? stopSelection : null + const getErrorStore = (selected, cellId) => { if (!selected) { return emptyError @@ -68,20 +82,38 @@ }) }, } + + const startSelection = e => { + if (e.button !== 0) { + return + } + focusedCellId.set(cellId) + cellSelection.actions.start(cellId) + } + + const updateSelection = e => { + cellSelection.actions.update(cellId) + } + + const stopSelection = e => { + cellSelection.actions.stop() + } focusedCellId.set(cellId)} on:contextmenu={e => menu.actions.open(cellId, e)} + on:mousedown={startSelection} + on:mouseenter={updateSelectionCallback} + on:mouseup={stopSelectionCallback} width={column.width} > {#if error} @@ -155,6 +156,7 @@ .cell.focused.readonly { --cell-background: var(--cell-background-hover); } + .cell.selected.focused, .cell.selected:not(.focused) { --cell-background: var(--spectrum-global-color-blue-100); } diff --git a/packages/frontend-core/src/components/grid/layout/GridRow.svelte b/packages/frontend-core/src/components/grid/layout/GridRow.svelte index c3d6f6eb86..6b05565f0e 100644 --- a/packages/frontend-core/src/components/grid/layout/GridRow.svelte +++ b/packages/frontend-core/src/components/grid/layout/GridRow.svelte @@ -19,10 +19,14 @@ dispatch, rows, columnRenderMap, + isSelectingCells, + selectedCells, + selectedCellCount, } = getContext("grid") $: rowSelected = !!$selectedRows[row._id] - $: rowHovered = $hoveredRowId === row._id + $: rowHovered = + $hoveredRowId === row._id && (!$selectedCellCount || !$isSelectingCells) $: rowFocused = $focusedRow?._id === row._id $: reorderSource = $reorder.sourceColumn @@ -43,8 +47,8 @@ {column} {row} {rowFocused} + {rowSelected} highlighted={rowHovered || rowFocused || reorderSource === column.name} - selected={rowSelected} rowIdx={row.__idx} topRow={top} focused={$focusedCellId === cellId} @@ -52,6 +56,8 @@ width={column.width} contentLines={$contentLines} hidden={!$columnRenderMap[column.name]} + isSelectingCells={$isSelectingCells} + selectedCells={$selectedCells} /> {/each} diff --git a/packages/frontend-core/src/components/grid/layout/StickyColumn.svelte b/packages/frontend-core/src/components/grid/layout/StickyColumn.svelte index b57c89ee4f..d98c1d3d25 100644 --- a/packages/frontend-core/src/components/grid/layout/StickyColumn.svelte +++ b/packages/frontend-core/src/components/grid/layout/StickyColumn.svelte @@ -24,6 +24,9 @@ dispatch, contentLines, isDragging, + isSelectingCells, + selectedCells, + selectedCellCount, } = getContext("grid") $: rowCount = $rows.length @@ -70,7 +73,9 @@ {#each $renderedRows as row, idx} {@const rowSelected = !!$selectedRows[row._id]} - {@const rowHovered = $hoveredRowId === row._id} + {@const rowHovered = + $hoveredRowId === row._id && + (!$selectedCellCount || !$isSelectingCells)} {@const rowFocused = $focusedRow?._id === row._id} {@const cellId = getCellID(row._id, $stickyColumn?.name)}
{/if}
diff --git a/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte b/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte index 5535538cf0..700670940d 100644 --- a/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte +++ b/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte @@ -21,7 +21,6 @@ notifications, hasBudibaseIdentifiers, selectedRowCount, - selectedRows, } = getContext("grid") let anchor @@ -83,6 +82,25 @@ > Delete {$selectedRowCount} rows
+ {:else if $menu.multiCellMode} + + Copy + + + Paste + + {}}> + Delete + {:else} { } export const deriveStores = context => { - const { columns, stickyColumn } = context + const { columns, stickyColumn, visibleColumns } = context // Quick access to all columns const allColumns = derived( @@ -67,9 +67,19 @@ export const deriveStores = context => { return normalCols.length > 0 }) + // Derive a lookup map for column indices by name + const columnLookupMap = derived(visibleColumns, $visibleColumns => { + let map = {} + $visibleColumns.forEach((column, idx) => { + map[column.name] = idx + }) + return map + }) + return { allColumns, hasNonAutoColumn, + columnLookupMap, } } diff --git a/packages/frontend-core/src/components/grid/stores/index.js b/packages/frontend-core/src/components/grid/stores/index.js index 6dfff6531b..a261023b2c 100644 --- a/packages/frontend-core/src/components/grid/stores/index.js +++ b/packages/frontend-core/src/components/grid/stores/index.js @@ -20,6 +20,7 @@ import * as Table from "./datasources/table" import * as ViewV2 from "./datasources/viewV2" import * as NonPlus from "./datasources/nonPlus" import * as Cache from "./cache" +import * as Selection from "./selection" const DependencyOrderedStores = [ Sort, @@ -44,6 +45,7 @@ const DependencyOrderedStores = [ Config, Notifications, Cache, + Selection, ] export const attachStores = context => { diff --git a/packages/frontend-core/src/components/grid/stores/menu.js b/packages/frontend-core/src/components/grid/stores/menu.js index 9120ad4a9f..295604b275 100644 --- a/packages/frontend-core/src/components/grid/stores/menu.js +++ b/packages/frontend-core/src/components/grid/stores/menu.js @@ -3,10 +3,11 @@ import { parseCellID } from "../lib/utils" export const createStores = () => { const menu = writable({ - x: 0, - y: 0, + left: 0, + top: 0, visible: false, - selectedRow: null, + multiRowMode: false, + multiCellMode: false, }) return { menu, @@ -14,8 +15,15 @@ export const createStores = () => { } export const createActions = context => { - const { menu, focusedCellId, gridID, selectedRows, selectedRowCount } = - context + const { + menu, + focusedCellId, + gridID, + selectedRows, + selectedRowCount, + selectedCells, + selectedCellCount, + } = context const open = (cellId, e) => { e.preventDefault() @@ -32,7 +40,7 @@ export const createActions = context => { const targetBounds = e.target.getBoundingClientRect() const dataBounds = dataNode.getBoundingClientRect() - // Check if there are multiple rows selected, and this is one of them + // Check if there are multiple rows selected, and if this is one of them let multiRowMode = false if (get(selectedRowCount) > 1) { const rowId = parseCellID(cellId).id @@ -41,8 +49,16 @@ export const createActions = context => { } } + // Check if there are multiple cells selected, and if this is one of them + let multiCellMode = false + if (!multiRowMode && get(selectedCellCount) > 1) { + if (get(selectedCells)[cellId]) { + multiCellMode = true + } + } + // Only focus this cell if not in multi row mode - if (!multiRowMode) { + if (!multiRowMode && !multiCellMode) { focusedCellId.set(cellId) } @@ -51,6 +67,7 @@ export const createActions = context => { top: targetBounds.top - dataBounds.top + e.offsetY, visible: true, multiRowMode, + multiCellMode, }) } diff --git a/packages/frontend-core/src/components/grid/stores/selection.js b/packages/frontend-core/src/components/grid/stores/selection.js new file mode 100644 index 0000000000..568c10eb19 --- /dev/null +++ b/packages/frontend-core/src/components/grid/stores/selection.js @@ -0,0 +1,130 @@ +import { derived, writable, get } from "svelte/store" +import { getCellID, parseCellID } from "../lib/utils" + +export const createStores = () => { + const cellSelection = writable({ + active: false, + sourceCellId: null, + targetCellId: null, + }) + + return { + cellSelection, + } +} + +export const deriveStores = context => { + const { cellSelection, rowLookupMap, columnLookupMap, rows, visibleColumns } = + context + + const isSelectingCells = derived(cellSelection, $cellSelection => { + return $cellSelection.active + }) + + const selectedCells = derived( + [cellSelection, rowLookupMap, columnLookupMap], + ([$cellSelection, $rowLookupMap, $columnLookupMap]) => { + const { sourceCellId, targetCellId } = $cellSelection + if (!sourceCellId || !targetCellId || sourceCellId === targetCellId) { + return {} + } + const $rows = get(rows) + const $visibleColumns = get(visibleColumns) + + // Get source and target row and column indices + const sourceInfo = parseCellID(sourceCellId) + const targetInfo = parseCellID(targetCellId) + + // Row indices + const sourceRowIndex = $rowLookupMap[sourceInfo.id] + const targetRowIndex = $rowLookupMap[targetInfo.id] + const lowerRowIndex = Math.min(sourceRowIndex, targetRowIndex) + const upperRowIndex = Math.max(sourceRowIndex, targetRowIndex) + + // Column indices + const sourceColIndex = $columnLookupMap[sourceInfo.field] + const targetColIndex = $columnLookupMap[targetInfo.field] + const lowerColIndex = Math.min(sourceColIndex, targetColIndex) + const upperColIndex = Math.max(sourceColIndex, targetColIndex) + + // Build map of all cells inside these bounds + let map = {} + let rowId, colName, cellId + for (let rowIdx = lowerRowIndex; rowIdx <= upperRowIndex; rowIdx++) { + for (let colIdx = lowerColIndex; colIdx <= upperColIndex; colIdx++) { + rowId = $rows[rowIdx]._id + colName = $visibleColumns[colIdx].name + cellId = getCellID(rowId, colName) + map[cellId] = true + } + } + return map + } + ) + + const selectedCellCount = derived(selectedCells, $selectedCells => { + return Object.keys($selectedCells).length + }) + + return { + isSelectingCells, + selectedCells, + selectedCellCount, + } +} + +export const createActions = context => { + const { cellSelection } = context + + const startCellSelection = sourceCellId => { + cellSelection.set({ + active: true, + sourceCellId, + targetCellId: sourceCellId, + }) + } + + const updateCellSelection = targetCellId => { + cellSelection.update(state => ({ + ...state, + targetCellId, + })) + } + + const stopCellSelection = () => { + cellSelection.update(state => ({ + ...state, + active: false, + })) + } + + const clearCellSelection = () => { + cellSelection.set({ + active: false, + sourceCellId: null, + targetCellId: null, + }) + } + + return { + cellSelection: { + ...cellSelection, + actions: { + start: startCellSelection, + update: updateCellSelection, + stop: stopCellSelection, + clear: clearCellSelection, + }, + }, + } +} + +export const initialise = context => { + const { selectedCellCount, selectedRowCount, selectedRows } = context + + selectedCellCount.subscribe($selectedCellCount => { + if ($selectedCellCount && get(selectedRowCount)) { + selectedRows.set({}) + } + }) +} diff --git a/packages/frontend-core/src/components/grid/stores/ui.js b/packages/frontend-core/src/components/grid/stores/ui.js index 6280b05eca..3aeb278c89 100644 --- a/packages/frontend-core/src/components/grid/stores/ui.js +++ b/packages/frontend-core/src/components/grid/stores/ui.js @@ -188,6 +188,9 @@ export const initialise = context => { rowHeight, fixedRowHeight, selectedRowCount, + menu, + cellSelection, + selectedCellCount, } = context // Ensure we clear invalid rows from state if they disappear @@ -248,6 +251,14 @@ export const initialise = context => { if (id && get(selectedRowCount)) { selectedRows.set({}) } + + // Clear cell selection when focusing a cell + if (id && get(selectedCellCount)) { + cellSelection.actions.clear() + } + + // Close the menu if it was open + menu.actions.close() }) // Pull row height from table as long as we don't have a fixed height @@ -268,8 +279,13 @@ export const initialise = context => { // Clear focused cell when selecting rows selectedRowCount.subscribe(count => { - if (get(focusedCellId) && count) { - focusedCellId.set(null) + if (count) { + if (get(focusedCellId)) { + focusedCellId.set(null) + } + if (get(selectedCellCount)) { + cellSelection.actions.clear() + } } }) } diff --git a/packages/frontend-core/src/themes/midnight.css b/packages/frontend-core/src/themes/midnight.css index 501bc164bc..30132e11e2 100644 --- a/packages/frontend-core/src/themes/midnight.css +++ b/packages/frontend-core/src/themes/midnight.css @@ -1,20 +1,20 @@ .spectrum--midnight { - --hue: 220; - --sat: 10%; - --spectrum-global-color-gray-50: hsl(var(--hue), var(--sat), 12%); - --spectrum-global-color-gray-75: hsl(var(--hue), var(--sat), 15%); - --spectrum-global-color-gray-100: hsl(var(--hue), var(--sat), 17%); - --spectrum-global-color-gray-200: hsl(var(--hue), var(--sat), 20%); - --spectrum-global-color-gray-300: hsl(var(--hue), var(--sat), 24%); - --spectrum-global-color-gray-400: hsl(var(--hue), var(--sat), 32%); - --spectrum-global-color-gray-500: hsl(var(--hue), var(--sat), 40%); - --spectrum-global-color-gray-600: hsl(var(--hue), var(--sat), 60%); - --spectrum-global-color-gray-700: hsl(var(--hue), var(--sat), 70%); - --spectrum-global-color-gray-800: hsl(var(--hue), var(--sat), 85%); - --spectrum-global-color-gray-900: hsl(var(--hue), var(--sat), 95%); + --hue: 220; + --sat: 10%; + --spectrum-global-color-gray-50: hsl(var(--hue), var(--sat), 12%); + --spectrum-global-color-gray-75: hsl(var(--hue), var(--sat), 15%); + --spectrum-global-color-gray-100: hsl(var(--hue), var(--sat), 17%); + --spectrum-global-color-gray-200: hsl(var(--hue), var(--sat), 20%); + --spectrum-global-color-gray-300: hsl(var(--hue), var(--sat), 24%); + --spectrum-global-color-gray-400: hsl(var(--hue), var(--sat), 32%); + --spectrum-global-color-gray-500: hsl(var(--hue), var(--sat), 40%); + --spectrum-global-color-gray-600: hsl(var(--hue), var(--sat), 60%); + --spectrum-global-color-gray-700: hsl(var(--hue), var(--sat), 70%); + --spectrum-global-color-gray-800: hsl(var(--hue), var(--sat), 85%); + --spectrum-global-color-gray-900: hsl(var(--hue), var(--sat), 95%); - /* Custom additions */ - --modal-background: var(--spectrum-global-color-gray-50); - --drop-shadow: rgba(0, 0, 0, 0.25) !important; - --spectrum-global-color-blue-100: rgba(35, 40, 50) !important; + /* Custom additions */ + --modal-background: var(--spectrum-global-color-gray-50); + --drop-shadow: rgba(0, 0, 0, 0.25) !important; + --spectrum-global-color-blue-100: rgba(36, 44, 64) !important; } diff --git a/packages/frontend-core/src/themes/nord.css b/packages/frontend-core/src/themes/nord.css index d47dbe8aa8..a99812b146 100644 --- a/packages/frontend-core/src/themes/nord.css +++ b/packages/frontend-core/src/themes/nord.css @@ -49,5 +49,5 @@ /* Custom additions */ --modal-background: var(--spectrum-global-color-gray-50); --drop-shadow: rgba(0, 0, 0, 0.15) !important; - --spectrum-global-color-blue-100: rgb(56, 65, 84) !important; + --spectrum-global-color-blue-100: rgb(56, 65, 90) !important; } From a86f891c0465cea02786fac3c6f4107d0748092e Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Fri, 21 Jun 2024 11:15:41 +0100 Subject: [PATCH 10/83] Update bulk cell selection to support sticky column --- .../src/components/grid/stores/columns.js | 19 ++++++++++++++++--- .../src/components/grid/stores/selection.js | 13 +++++++++---- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/frontend-core/src/components/grid/stores/columns.js b/packages/frontend-core/src/components/grid/stores/columns.js index f5eb3d22fc..e0c44db1df 100644 --- a/packages/frontend-core/src/components/grid/stores/columns.js +++ b/packages/frontend-core/src/components/grid/stores/columns.js @@ -53,7 +53,19 @@ export const deriveStores = context => { ([$columns, $stickyColumn]) => { let allCols = $columns || [] if ($stickyColumn) { - allCols = [...allCols, $stickyColumn] + allCols = [$stickyColumn, ...allCols] + } + return allCols + } + ) + + // Quick access to all visible columns + const allVisibleColumns = derived( + [visibleColumns, stickyColumn], + ([$visibleColumns, $stickyColumn]) => { + let allCols = $visibleColumns || [] + if ($stickyColumn) { + allCols = [$stickyColumn, ...allCols] } return allCols } @@ -68,9 +80,9 @@ export const deriveStores = context => { }) // Derive a lookup map for column indices by name - const columnLookupMap = derived(visibleColumns, $visibleColumns => { + const columnLookupMap = derived(allVisibleColumns, $allVisibleColumns => { let map = {} - $visibleColumns.forEach((column, idx) => { + $allVisibleColumns.forEach((column, idx) => { map[column.name] = idx }) return map @@ -78,6 +90,7 @@ export const deriveStores = context => { return { allColumns, + allVisibleColumns, hasNonAutoColumn, columnLookupMap, } diff --git a/packages/frontend-core/src/components/grid/stores/selection.js b/packages/frontend-core/src/components/grid/stores/selection.js index 568c10eb19..ba1b1b5d32 100644 --- a/packages/frontend-core/src/components/grid/stores/selection.js +++ b/packages/frontend-core/src/components/grid/stores/selection.js @@ -14,8 +14,13 @@ export const createStores = () => { } export const deriveStores = context => { - const { cellSelection, rowLookupMap, columnLookupMap, rows, visibleColumns } = - context + const { + cellSelection, + rowLookupMap, + columnLookupMap, + rows, + allVisibleColumns, + } = context const isSelectingCells = derived(cellSelection, $cellSelection => { return $cellSelection.active @@ -29,7 +34,7 @@ export const deriveStores = context => { return {} } const $rows = get(rows) - const $visibleColumns = get(visibleColumns) + const $allVisibleColumns = get(allVisibleColumns) // Get source and target row and column indices const sourceInfo = parseCellID(sourceCellId) @@ -53,7 +58,7 @@ export const deriveStores = context => { for (let rowIdx = lowerRowIndex; rowIdx <= upperRowIndex; rowIdx++) { for (let colIdx = lowerColIndex; colIdx <= upperColIndex; colIdx++) { rowId = $rows[rowIdx]._id - colName = $visibleColumns[colIdx].name + colName = $allVisibleColumns[colIdx].name cellId = getCellID(rowId, colName) map[cellId] = true } From 502c2541e576de262232bd6478f293a7743bc314 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Fri, 21 Jun 2024 15:17:49 +0100 Subject: [PATCH 11/83] Clean up and improve copy/paste flows --- .../controls/BulkDuplicationHandler.svelte | 5 - .../grid/controls/ClipboardHandler.svelte | 45 ++++++++ .../src/components/grid/layout/Grid.svelte | 2 + .../grid/overlays/KeyboardManager.svelte | 7 +- .../grid/overlays/MenuOverlay.svelte | 39 +++---- .../src/components/grid/stores/clipboard.js | 100 ++++++++++++++++-- .../src/components/grid/stores/index.js | 2 +- 7 files changed, 158 insertions(+), 42 deletions(-) create mode 100644 packages/frontend-core/src/components/grid/controls/ClipboardHandler.svelte diff --git a/packages/frontend-core/src/components/grid/controls/BulkDuplicationHandler.svelte b/packages/frontend-core/src/components/grid/controls/BulkDuplicationHandler.svelte index bc321fffde..4fbf79b81c 100644 --- a/packages/frontend-core/src/components/grid/controls/BulkDuplicationHandler.svelte +++ b/packages/frontend-core/src/components/grid/controls/BulkDuplicationHandler.svelte @@ -10,7 +10,6 @@ focusedCellId, stickyColumn, columns, - menu, selectedRowCount, } = getContext("grid") @@ -18,7 +17,6 @@ // Deletion callback when confirmed const performDuplication = async () => { - menu.actions.close() const rowsToDuplicate = Object.keys($selectedRows).map(id => { return rows.actions.getRow(id) }) @@ -27,9 +25,6 @@ const column = $stickyColumn?.name || $columns[0].name $focusedCellId = getCellID(newRows[0]._id, column) } - - // Ensure menu is closed, as we may have triggered this from there - menu.actions.close() } onMount(() => subscribe("request-bulk-duplicate", () => modal?.show())) diff --git a/packages/frontend-core/src/components/grid/controls/ClipboardHandler.svelte b/packages/frontend-core/src/components/grid/controls/ClipboardHandler.svelte new file mode 100644 index 0000000000..87b881e8ad --- /dev/null +++ b/packages/frontend-core/src/components/grid/controls/ClipboardHandler.svelte @@ -0,0 +1,45 @@ + + + + + Are you sure you want to paste values into {$selectedCellCount} cells? + + diff --git a/packages/frontend-core/src/components/grid/layout/Grid.svelte b/packages/frontend-core/src/components/grid/layout/Grid.svelte index 8496aebd35..6c61fdd45e 100644 --- a/packages/frontend-core/src/components/grid/layout/Grid.svelte +++ b/packages/frontend-core/src/components/grid/layout/Grid.svelte @@ -8,6 +8,7 @@ import { attachStores } from "../stores" import BulkDeleteHandler from "../controls/BulkDeleteHandler.svelte" import BulkDuplicationHandler from "../controls/BulkDuplicationHandler.svelte" + import ClipboardHandler from "../controls/ClipboardHandler.svelte" import GridBody from "./GridBody.svelte" import ResizeOverlay from "../overlays/ResizeOverlay.svelte" import ReorderOverlay from "../overlays/ReorderOverlay.svelte" @@ -216,6 +217,7 @@ {#if $config.canAddRows} {/if} + diff --git a/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte b/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte index f38eb8d997..0da90298b6 100644 --- a/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte +++ b/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte @@ -11,7 +11,6 @@ focusedRow, stickyColumn, focusedCellAPI, - clipboard, dispatch, selectedRows, config, @@ -97,12 +96,10 @@ if (e.metaKey || e.ctrlKey) { switch (e.key) { case "c": - clipboard.actions.copy() + dispatch("copy") break case "v": - if (!api?.isReadonly()) { - clipboard.actions.paste() - } + dispatch("paste") break case "Enter": if ($config.canAddRows) { diff --git a/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte b/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte index 700670940d..99ecc602a4 100644 --- a/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte +++ b/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte @@ -13,14 +13,13 @@ focusedCellId, stickyColumn, config, - copiedCell, - clipboard, dispatch, - focusedCellAPI, focusedRowId, notifications, hasBudibaseIdentifiers, selectedRowCount, + copyAllowed, + pasteAllowed, } = getContext("grid") let anchor @@ -33,16 +32,12 @@ } const deleteRow = () => { - rows.actions.deleteRows([$focusedRow]) menu.actions.close() + rows.actions.deleteRows([$focusedRow]) $notifications.success("Deleted 1 row") } - const bulkDelete = () => { - dispatch("request-bulk-delete") - } - - const duplicate = async () => { + const duplicateRow = async () => { menu.actions.close() const newRow = await rows.actions.duplicateRow($focusedRow) if (newRow) { @@ -51,10 +46,6 @@ } } - const bulkDuplicate = () => { - dispatch("request-bulk-duplicate") - } - const copyToClipboard = async value => { await Helpers.copyToClipboard(value) $notifications.success("Copied to clipboard") @@ -71,29 +62,32 @@ 50} - on:click={bulkDuplicate} + on:click={() => dispatch("request-bulk-duplicate")} + on:click={menu.actions.close} > Duplicate {$selectedRowCount} rows dispatch("request-bulk-delete")} + on:click={menu.actions.close} > Delete {$selectedRowCount} rows {:else if $menu.multiCellMode} dispatch("copy")} on:click={menu.actions.close} > Copy dispatch("paste")} on:click={menu.actions.close} > Paste @@ -104,15 +98,16 @@ {:else} dispatch("copy")} on:click={menu.actions.close} > Copy dispatch("paste")} on:click={menu.actions.close} > Paste @@ -148,7 +143,7 @@ Duplicate row diff --git a/packages/frontend-core/src/components/grid/stores/clipboard.js b/packages/frontend-core/src/components/grid/stores/clipboard.js index 200df29902..72bb3fa1ac 100644 --- a/packages/frontend-core/src/components/grid/stores/clipboard.js +++ b/packages/frontend-core/src/components/grid/stores/clipboard.js @@ -1,20 +1,79 @@ -import { writable, get } from "svelte/store" +import { derived, writable, get } from "svelte/store" import { Helpers } from "@budibase/bbui" export const createStores = () => { - const copiedCell = writable(null) + const clipboard = writable({ + value: null, + multiCellMode: false, + }) return { - copiedCell, + clipboard, + } +} + +export const deriveStores = context => { + const { clipboard, focusedCellAPI, selectedCellCount } = context + + const copyAllowed = derived(focusedCellAPI, $focusedCellAPI => { + return $focusedCellAPI != null + }) + + const pasteAllowed = derived( + [clipboard, focusedCellAPI, selectedCellCount], + ([$clipboard, $focusedCellAPI, $selectedCellCount]) => { + if ($clipboard.value == null || !$focusedCellAPI) { + return false + } + // Prevent pasting into a single cell, if we have a single cell value and + // this cell is readonly + const multiCellPaste = $selectedCellCount > 1 + if ( + !$clipboard.multiCellMode && + !multiCellPaste && + $focusedCellAPI.isReadonly() + ) { + return false + } + return true + } + ) + + return { + copyAllowed, + pasteAllowed, } } export const createActions = context => { - const { copiedCell, focusedCellAPI } = context + const { + clipboard, + selectedCellCount, + focusedCellAPI, + copyAllowed, + pasteAllowed, + } = context const copy = () => { - const value = get(focusedCellAPI)?.getValue() - copiedCell.set(value) + if (!get(copyAllowed)) { + return + } + const $selectedCellCount = get(selectedCellCount) + const $focusedCellAPI = get(focusedCellAPI) + const multiCellMode = $selectedCellCount > 1 + + // Multiple values to copy + if (multiCellMode) { + // TODO + return + } + + // Single value to copy + const value = $focusedCellAPI.getValue() + clipboard.set({ + value, + multiCellMode, + }) // Also copy a stringified version to the clipboard let stringified = "" @@ -26,15 +85,38 @@ export const createActions = context => { } const paste = () => { - const $copiedCell = get(copiedCell) + if (!get(pasteAllowed)) { + return + } + const $clipboard = get(clipboard) const $focusedCellAPI = get(focusedCellAPI) - if ($copiedCell != null && $focusedCellAPI) { - $focusedCellAPI.setValue($copiedCell) + if ($clipboard.value == null || !$focusedCellAPI) { + return + } + + // Check if we're pasting into one or more cells + const $selectedCellCount = get(selectedCellCount) + const multiCellPaste = $selectedCellCount > 1 + + if ($clipboard.multiCellMode) { + if (multiCellPaste) { + // Multi to multi (only paste selected cells) + } else { + // Multi to single (expand to paste all values) + } + } else { + if (multiCellPaste) { + // Single to multi (duplicate value in all selected cells) + } else { + // Single to single + $focusedCellAPI.setValue($clipboard.value) + } } } return { clipboard: { + ...clipboard, actions: { copy, paste, diff --git a/packages/frontend-core/src/components/grid/stores/index.js b/packages/frontend-core/src/components/grid/stores/index.js index a261023b2c..011c5fda12 100644 --- a/packages/frontend-core/src/components/grid/stores/index.js +++ b/packages/frontend-core/src/components/grid/stores/index.js @@ -41,11 +41,11 @@ const DependencyOrderedStores = [ Users, Menu, Pagination, + Selection, Clipboard, Config, Notifications, Cache, - Selection, ] export const attachStores = context => { From ad0d300ff9d5541cc90c1a30b8b6b2b186151c88 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Fri, 21 Jun 2024 16:30:51 +0100 Subject: [PATCH 12/83] Add support for bulk pasting a single value into multiple cells --- .../src/components/grid/cells/DataCell.svelte | 7 +- .../src/components/grid/stores/clipboard.js | 18 ++- .../src/components/grid/stores/index.js | 2 +- .../src/components/grid/stores/rows.js | 107 +++++++++++++++--- .../src/components/grid/stores/validation.js | 59 +++++++--- 5 files changed, 152 insertions(+), 41 deletions(-) diff --git a/packages/frontend-core/src/components/grid/cells/DataCell.svelte b/packages/frontend-core/src/components/grid/cells/DataCell.svelte index d0ad26e939..51f7e75640 100644 --- a/packages/frontend-core/src/components/grid/cells/DataCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/DataCell.svelte @@ -34,8 +34,10 @@ let api - // Get the error for this cell if the row is focused - $: error = getErrorStore(rowFocused, cellId) + $: cellSelected = selectedCells[cellId] + + // Get the error for this cell if the cell is focused or selected + $: error = getErrorStore(rowFocused || cellSelected, cellId) // Determine if the cell is editable $: readonly = @@ -53,7 +55,6 @@ } // Callbacks for cell selection - $: cellSelected = selectedCells[cellId] $: updateSelectionCallback = isSelectingCells ? updateSelection : null $: stopSelectionCallback = isSelectingCells ? stopSelection : null diff --git a/packages/frontend-core/src/components/grid/stores/clipboard.js b/packages/frontend-core/src/components/grid/stores/clipboard.js index 72bb3fa1ac..25272d61b0 100644 --- a/packages/frontend-core/src/components/grid/stores/clipboard.js +++ b/packages/frontend-core/src/components/grid/stores/clipboard.js @@ -1,5 +1,6 @@ import { derived, writable, get } from "svelte/store" import { Helpers } from "@budibase/bbui" +import { parseCellID } from "../lib/utils" export const createStores = () => { const clipboard = writable({ @@ -52,6 +53,8 @@ export const createActions = context => { focusedCellAPI, copyAllowed, pasteAllowed, + rows, + selectedCells, } = context const copy = () => { @@ -84,7 +87,7 @@ export const createActions = context => { Helpers.copyToClipboard(stringified) } - const paste = () => { + const paste = async () => { if (!get(pasteAllowed)) { return } @@ -95,8 +98,8 @@ export const createActions = context => { } // Check if we're pasting into one or more cells - const $selectedCellCount = get(selectedCellCount) - const multiCellPaste = $selectedCellCount > 1 + const cellIds = Object.keys(get(selectedCells)) + const multiCellPaste = cellIds.length > 1 if ($clipboard.multiCellMode) { if (multiCellPaste) { @@ -107,6 +110,15 @@ export const createActions = context => { } else { if (multiCellPaste) { // Single to multi (duplicate value in all selected cells) + let changeMap = {} + for (let cellId of cellIds) { + const { id, field } = parseCellID(cellId) + if (!changeMap[id]) { + changeMap[id] = {} + } + changeMap[id][field] = $clipboard.value + } + await rows.actions.bulkUpdate(changeMap) } else { // Single to single $focusedCellAPI.setValue($clipboard.value) diff --git a/packages/frontend-core/src/components/grid/stores/index.js b/packages/frontend-core/src/components/grid/stores/index.js index 011c5fda12..cb7f5b1106 100644 --- a/packages/frontend-core/src/components/grid/stores/index.js +++ b/packages/frontend-core/src/components/grid/stores/index.js @@ -32,9 +32,9 @@ const DependencyOrderedStores = [ NonPlus, Datasource, Columns, + Validation, Rows, UI, - Validation, Resize, Viewport, Reorder, diff --git a/packages/frontend-core/src/components/grid/stores/rows.js b/packages/frontend-core/src/components/grid/stores/rows.js index f23ce1e1b3..93e6463afd 100644 --- a/packages/frontend-core/src/components/grid/stores/rows.js +++ b/packages/frontend-core/src/components/grid/stores/rows.js @@ -264,11 +264,6 @@ export const createActions = context => { for (let column of missingColumns) { get(notifications).error(`${column} is required but is missing`) } - - // Focus the first cell with an error - if (erroredColumns.length) { - focusedCellId.set(getCellID(rowId, erroredColumns[0])) - } } else { get(notifications).error(errorString || "An unknown error occurred") } @@ -299,6 +294,7 @@ export const createActions = context => { throw error } else { handleValidationError(NewRowID, error) + validation.actions.focusFirstRowError(NewRowID) } } } @@ -319,6 +315,7 @@ export const createActions = context => { return duped } catch (error) { handleValidationError(row._id, error) + validation.actions.focusFirstRowError(row._id) } } @@ -447,8 +444,14 @@ export const createActions = context => { return true } - // Saves any pending changes to a row - const applyRowChanges = async rowId => { + // Saves any pending changes to a row, as well as any additional changes + // specified + const applyRowChanges = async ({ + rowId, + changes = null, + updateState = true, + handleErrors = true, + }) => { const $rows = get(rows) const $rowLookupMap = get(rowLookupMap) const index = $rowLookupMap[rowId] @@ -456,6 +459,7 @@ export const createActions = context => { if (row == null) { return } + let savedRow // Save change try { @@ -466,19 +470,24 @@ export const createActions = context => { })) // Update row - const changes = get(rowChangeCache)[rowId] - const newRow = { ...cleanRow(row), ...changes } - const saved = await datasource.actions.updateRow(newRow) + const newRow = { + ...cleanRow(row), + ...get(rowChangeCache)[rowId], + ...changes, + } + savedRow = await datasource.actions.updateRow(newRow) // Update row state after a successful change - if (saved?._id) { - rows.update(state => { - state[index] = saved - return state.slice() - }) - } else if (saved?.id) { + if (savedRow?._id) { + if (updateState) { + rows.update(state => { + state[index] = savedRow + return state.slice() + }) + } + } else if (savedRow?.id) { // Handle users table edge case - await refreshRow(saved.id) + await refreshRow(savedRow.id) } // Wipe row change cache for any values which have been saved @@ -492,7 +501,10 @@ export const createActions = context => { return state }) } catch (error) { - handleValidationError(rowId, error) + if (handleErrors) { + handleValidationError(rowId, error) + validation.actions.focusFirstRowError(rowId) + } } // Decrement change count for this row @@ -500,6 +512,7 @@ export const createActions = context => { ...state, [rowId]: (state[rowId] || 1) - 1, })) + return savedRow } // Updates a value of a row @@ -510,6 +523,63 @@ export const createActions = context => { } } + const bulkUpdate = async changeMap => { + const rowIds = Object.keys(changeMap || {}) + if (!rowIds.length) { + return + } + + // Update rows + let updated = [] + let failed = 0 + for (let rowId of rowIds) { + if (!Object.keys(changeMap[rowId] || {}).length) { + continue + } + try { + const updatedRow = await applyRowChanges({ + rowId, + changes: changeMap[rowId], + updateState: false, + handleErrors: false, + }) + if (updatedRow) { + updated.push(updatedRow) + } else { + failed++ + } + await sleep(50) // Small sleep to ensure we avoid rate limiting + } catch (error) { + failed++ + console.error("Failed to update row", error) + } + } + + // Update state + if (updated.length) { + const $rowLookupMap = get(rowLookupMap) + rows.update(state => { + for (let row of updated) { + const index = $rowLookupMap[row._id] + state[index] = row + } + return state.slice() + }) + } + + // Notify user + if (updated.length) { + get(notifications).success( + `Updated ${updated.length} row${updated.length === 1 ? "" : "s"}` + ) + } + if (failed) { + get(notifications).error( + `Failed to update ${failed} row${failed === 1 ? "" : "s"}` + ) + } + } + // Deletes an array of rows const deleteRows = async rowsToDelete => { if (!rowsToDelete?.length) { @@ -607,6 +677,7 @@ export const createActions = context => { replaceRow, refreshData, cleanRow, + bulkUpdate, }, }, } diff --git a/packages/frontend-core/src/components/grid/stores/validation.js b/packages/frontend-core/src/components/grid/stores/validation.js index 34efde9180..6dd98ffff9 100644 --- a/packages/frontend-core/src/components/grid/stores/validation.js +++ b/packages/frontend-core/src/components/grid/stores/validation.js @@ -1,5 +1,5 @@ import { writable, get, derived } from "svelte/store" -import { getCellID, parseCellID } from "../lib/utils" +import { parseCellID } from "../lib/utils" // Normally we would break out actions into the explicit "createActions" // function, but for validation all these actions are pure so can go into @@ -7,18 +7,38 @@ import { getCellID, parseCellID } from "../lib/utils" export const createStores = () => { const validation = writable({}) + return { + validation, + } +} + +export const deriveStores = context => { + const { validation } = context + // Derive which rows have errors so that we can use that info later - const rowErrorMap = derived(validation, $validation => { + const validationRowLookupMap = derived(validation, $validation => { let map = {} Object.entries($validation).forEach(([key, error]) => { // Extract row ID from all errored cell IDs if (error) { - map[parseCellID(key).id] = true + const rowId = parseCellID(key).id + if (!map[rowId]) { + map[rowId] = [] + } + map[rowId].push(key) } }) return map }) + return { + validationRowLookupMap, + } +} + +export const createActions = context => { + const { validation, focusedCellId, validationRowLookupMap } = context + const setError = (cellId, error) => { if (!cellId) { return @@ -30,7 +50,15 @@ export const createStores = () => { } const rowHasErrors = rowId => { - return get(rowErrorMap)[rowId] + return get(validationRowLookupMap)[rowId]?.length > 0 + } + + const focusFirstRowError = rowId => { + const errorCells = get(validationRowLookupMap)[rowId] + const cellId = errorCells?.[0] + if (cellId) { + focusedCellId.set(cellId) + } } return { @@ -39,28 +67,27 @@ export const createStores = () => { actions: { setError, rowHasErrors, + focusFirstRowError, }, }, } } export const initialise = context => { - const { validation, previousFocusedRowId, columns, stickyColumn } = context + const { validation, previousFocusedRowId, validationRowLookupMap } = context - // Remove validation errors from previous focused row + // Remove validation errors when changing rows previousFocusedRowId.subscribe(id => { if (id) { - const $columns = get(columns) - const $stickyColumn = get(stickyColumn) - validation.update(state => { - $columns.forEach(column => { - state[getCellID(id, column.name)] = null + const errorCells = get(validationRowLookupMap)[id] + if (errorCells?.length) { + validation.update(state => { + for (let cellId of errorCells) { + delete state[cellId] + } + return state }) - if ($stickyColumn) { - state[getCellID(id, stickyColumn.name)] = null - } - return state - }) + } } }) } From 9657781df685cb70becc92be5adb6fb468eabccf Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Fri, 21 Jun 2024 20:38:48 +0100 Subject: [PATCH 13/83] Add multi to multi pasting --- .../src/components/grid/stores/clipboard.js | 131 ++++++++++++++---- .../src/components/grid/stores/selection.js | 2 +- 2 files changed, 107 insertions(+), 26 deletions(-) diff --git a/packages/frontend-core/src/components/grid/stores/clipboard.js b/packages/frontend-core/src/components/grid/stores/clipboard.js index 25272d61b0..17b39c134b 100644 --- a/packages/frontend-core/src/components/grid/stores/clipboard.js +++ b/packages/frontend-core/src/components/grid/stores/clipboard.js @@ -5,9 +5,8 @@ import { parseCellID } from "../lib/utils" export const createStores = () => { const clipboard = writable({ value: null, - multiCellMode: false, + multiCellCopy: false, }) - return { clipboard, } @@ -16,10 +15,12 @@ export const createStores = () => { export const deriveStores = context => { const { clipboard, focusedCellAPI, selectedCellCount } = context + // Derive whether or not we're able to copy const copyAllowed = derived(focusedCellAPI, $focusedCellAPI => { return $focusedCellAPI != null }) + // Derive whether or not we're able to paste const pasteAllowed = derived( [clipboard, focusedCellAPI, selectedCellCount], ([$clipboard, $focusedCellAPI, $selectedCellCount]) => { @@ -30,7 +31,7 @@ export const deriveStores = context => { // this cell is readonly const multiCellPaste = $selectedCellCount > 1 if ( - !$clipboard.multiCellMode && + !$clipboard.multiCellCopy && !multiCellPaste && $focusedCellAPI.isReadonly() ) { @@ -49,44 +50,93 @@ export const deriveStores = context => { export const createActions = context => { const { clipboard, - selectedCellCount, focusedCellAPI, copyAllowed, pasteAllowed, - rows, selectedCells, + rowLookupMap, + rowChangeCache, + rows, + columnLookupMap, } = context + // Copies the currently selected value (or values) const copy = () => { if (!get(copyAllowed)) { return } - const $selectedCellCount = get(selectedCellCount) + const cellIds = Object.keys(get(selectedCells)) const $focusedCellAPI = get(focusedCellAPI) - const multiCellMode = $selectedCellCount > 1 + const multiCellCopy = cellIds.length > 1 // Multiple values to copy - if (multiCellMode) { - // TODO - return - } + if (multiCellCopy) { + const $rowLookupMap = get(rowLookupMap) + const $rowChangeCache = get(rowChangeCache) + const $rows = get(rows) + const $columnLookupMap = get(columnLookupMap) - // Single value to copy - const value = $focusedCellAPI.getValue() - clipboard.set({ - value, - multiCellMode, - }) + // Go through each selected cell and group all selected cell values by + // their row ID. Order is important for pasting, so we store the index of + // both rows and values. + let map = {} + for (let cellId of cellIds) { + const { id, field } = parseCellID(cellId) + const index = $rowLookupMap[id] + if (!map[id]) { + map[id] = { + order: index, + values: [], + } + } + const row = { + ...$rows[index], + ...$rowChangeCache[id], + } + const columnIndex = $columnLookupMap[field] + map[id].values.push({ + value: row[field], + order: columnIndex, + }) + } - // Also copy a stringified version to the clipboard - let stringified = "" - if (value != null && value !== "") { - // Only conditionally stringify to avoid redundant quotes around text - stringified = typeof value === "object" ? JSON.stringify(value) : value + // Sort rows by order + let value = [] + const sortedRowValues = Object.values(map) + .toSorted((a, b) => a.order - b.order) + .map(x => x.values) + + // Sort all values in each row by order + for (let rowValues of sortedRowValues) { + value.push( + rowValues.toSorted((a, b) => a.order - b.order).map(x => x.value) + ) + } + + // Update state + clipboard.set({ + value, + multiCellCopy: true, + }) + } else { + // Single value to copy + const value = $focusedCellAPI.getValue() + clipboard.set({ + value, + multiCellCopy, + }) + + // Also copy a stringified version to the clipboard + let stringified = "" + if (value != null && value !== "") { + // Only conditionally stringify to avoid redundant quotes around text + stringified = typeof value === "object" ? JSON.stringify(value) : value + } + Helpers.copyToClipboard(stringified) } - Helpers.copyToClipboard(stringified) } + // Pastes the previously copied value(s) into the selected cell(s) const paste = async () => { if (!get(pasteAllowed)) { return @@ -98,14 +148,45 @@ export const createActions = context => { } // Check if we're pasting into one or more cells - const cellIds = Object.keys(get(selectedCells)) + const $selectedCells = get(selectedCells) + const cellIds = Object.keys($selectedCells) const multiCellPaste = cellIds.length > 1 - if ($clipboard.multiCellMode) { + if ($clipboard.multiCellCopy) { if (multiCellPaste) { // Multi to multi (only paste selected cells) + const value = $clipboard.value + + // Find the top left index so we can find the relative offset for each + // cell + let rowIndices = [] + let columnIndices = [] + for (let cellId of cellIds) { + rowIndices.push($selectedCells[cellId].rowIdx) + columnIndices.push($selectedCells[cellId].colIdx) + } + const minRowIdx = Math.min(...rowIndices) + const minColIdx = Math.min(...columnIndices) + + // Build change map of values to patch + let changeMap = {} + const $rowLookupMap = get(rowLookupMap) + const $columnLookupMap = get(columnLookupMap) + for (let cellId of cellIds) { + const { id, field } = parseCellID(cellId) + const rowIdx = $rowLookupMap[id] - minRowIdx + const colIdx = $columnLookupMap[field] - minColIdx + if (colIdx in (value[rowIdx] || [])) { + if (!changeMap[id]) { + changeMap[id] = {} + } + changeMap[id][field] = value[rowIdx][colIdx] + } + } + await rows.actions.bulkUpdate(changeMap) } else { // Multi to single (expand to paste all values) + // TODO } } else { if (multiCellPaste) { diff --git a/packages/frontend-core/src/components/grid/stores/selection.js b/packages/frontend-core/src/components/grid/stores/selection.js index ba1b1b5d32..bcbdd50079 100644 --- a/packages/frontend-core/src/components/grid/stores/selection.js +++ b/packages/frontend-core/src/components/grid/stores/selection.js @@ -60,7 +60,7 @@ export const deriveStores = context => { rowId = $rows[rowIdx]._id colName = $allVisibleColumns[colIdx].name cellId = getCellID(rowId, colName) - map[cellId] = true + map[cellId] = { rowIdx, colIdx } } } return map From 6633cc3cbcf59b9b1109cf03c04f71443fd587da Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Fri, 21 Jun 2024 20:48:55 +0100 Subject: [PATCH 14/83] Fix normal row changes not working --- .../frontend-core/src/components/grid/stores/rows.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/frontend-core/src/components/grid/stores/rows.js b/packages/frontend-core/src/components/grid/stores/rows.js index 93e6463afd..f78a57fe1c 100644 --- a/packages/frontend-core/src/components/grid/stores/rows.js +++ b/packages/frontend-core/src/components/grid/stores/rows.js @@ -470,9 +470,10 @@ export const createActions = context => { })) // Update row + const stashedChanges = get(rowChangeCache)[rowId] const newRow = { ...cleanRow(row), - ...get(rowChangeCache)[rowId], + ...stashedChanges, ...changes, } savedRow = await datasource.actions.updateRow(newRow) @@ -493,8 +494,8 @@ export const createActions = context => { // Wipe row change cache for any values which have been saved const liveChanges = get(rowChangeCache)[rowId] rowChangeCache.update(state => { - Object.keys(changes || {}).forEach(key => { - if (changes[key] === liveChanges?.[key]) { + Object.keys(stashedChanges || {}).forEach(key => { + if (stashedChanges[key] === liveChanges?.[key]) { delete state[rowId][key] } }) @@ -519,7 +520,7 @@ export const createActions = context => { const updateValue = async ({ rowId, column, value, apply = true }) => { const success = stashRowChanges(rowId, { [column]: value }) if (success && apply) { - await applyRowChanges(rowId) + await applyRowChanges({ rowId }) } } @@ -715,7 +716,7 @@ export const initialise = context => { const hasErrors = validation.actions.rowHasErrors(rowId) const isSavingChanges = get(inProgressChanges)[rowId] if (rowId && !hasErrors && hasChanges && !isSavingChanges) { - await rows.actions.applyRowChanges(rowId) + await rows.actions.applyRowChanges({ rowId }) } }) } From 60d86c8b143b3c3625df947f2468fc648201dfc1 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Fri, 21 Jun 2024 20:58:56 +0100 Subject: [PATCH 15/83] Update keybindings to account for new copy/paste functions --- .../src/components/grid/cells/DataCell.svelte | 4 ++- .../grid/overlays/KeyboardManager.svelte | 34 ++++++++++--------- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/packages/frontend-core/src/components/grid/cells/DataCell.svelte b/packages/frontend-core/src/components/grid/cells/DataCell.svelte index 51f7e75640..ba9dc60a26 100644 --- a/packages/frontend-core/src/components/grid/cells/DataCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/DataCell.svelte @@ -88,11 +88,12 @@ if (e.button !== 0) { return } - focusedCellId.set(cellId) + // focusedCellId.set(cellId) cellSelection.actions.start(cellId) } const updateSelection = e => { + focusedCellId.set(null) cellSelection.actions.update(cellId) } @@ -115,6 +116,7 @@ on:mousedown={startSelection} on:mouseenter={updateSelectionCallback} on:mouseup={stopSelectionCallback} + on:click={() => focusedCellId.set(cellId)} width={column.width} > Date: Sat, 22 Jun 2024 13:53:31 +0100 Subject: [PATCH 16/83] Refactor stores --- .../src/components/grid/cells/DataCell.svelte | 12 +- .../src/components/grid/layout/GridRow.svelte | 2 +- .../grid/layout/GridScrollWrapper.svelte | 4 +- .../grid/layout/StickyColumn.svelte | 2 +- .../src/components/grid/stores/index.js | 2 - .../src/components/grid/stores/selection.js | 135 ------------------ .../src/components/grid/stores/ui.js | 121 +++++++++++++++- 7 files changed, 126 insertions(+), 152 deletions(-) delete mode 100644 packages/frontend-core/src/components/grid/stores/selection.js diff --git a/packages/frontend-core/src/components/grid/cells/DataCell.svelte b/packages/frontend-core/src/components/grid/cells/DataCell.svelte index ba9dc60a26..800d612824 100644 --- a/packages/frontend-core/src/components/grid/cells/DataCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/DataCell.svelte @@ -11,7 +11,7 @@ menu, config, validation, - cellSelection, + selectedCells, } = getContext("grid") export let highlighted @@ -28,14 +28,12 @@ export let contentLines = 1 export let hidden = false export let isSelectingCells = false - export let selectedCells = {} + export let cellSelected = false const emptyError = writable(null) let api - $: cellSelected = selectedCells[cellId] - // Get the error for this cell if the cell is focused or selected $: error = getErrorStore(rowFocused || cellSelected, cellId) @@ -89,16 +87,16 @@ return } // focusedCellId.set(cellId) - cellSelection.actions.start(cellId) + selectedCells.actions.start(cellId) } const updateSelection = e => { focusedCellId.set(null) - cellSelection.actions.update(cellId) + selectedCells.actions.update(cellId) } const stopSelection = e => { - cellSelection.actions.stop() + selectedCells.actions.stop() } diff --git a/packages/frontend-core/src/components/grid/layout/GridRow.svelte b/packages/frontend-core/src/components/grid/layout/GridRow.svelte index 6b05565f0e..a01d9de7df 100644 --- a/packages/frontend-core/src/components/grid/layout/GridRow.svelte +++ b/packages/frontend-core/src/components/grid/layout/GridRow.svelte @@ -48,6 +48,7 @@ {row} {rowFocused} {rowSelected} + cellSelected={$selectedCells[cellId]} highlighted={rowHovered || rowFocused || reorderSource === column.name} rowIdx={row.__idx} topRow={top} @@ -57,7 +58,6 @@ contentLines={$contentLines} hidden={!$columnRenderMap[column.name]} isSelectingCells={$isSelectingCells} - selectedCells={$selectedCells} /> {/each} diff --git a/packages/frontend-core/src/components/grid/layout/GridScrollWrapper.svelte b/packages/frontend-core/src/components/grid/layout/GridScrollWrapper.svelte index 763b01dd84..e1f3339169 100644 --- a/packages/frontend-core/src/components/grid/layout/GridScrollWrapper.svelte +++ b/packages/frontend-core/src/components/grid/layout/GridScrollWrapper.svelte @@ -5,7 +5,7 @@ const { rowHeight, scroll, - focusedCellId, + ui, renderedRows, maxScrollTop, maxScrollLeft, @@ -108,7 +108,7 @@ on:wheel={attachHandlers ? handleWheel : null} on:touchstart={attachHandlers ? handleTouchStart : null} on:touchmove={attachHandlers ? handleTouchMove : null} - on:click|self={() => ($focusedCellId = null)} + on:click|self={ui.actions.blur} >
diff --git a/packages/frontend-core/src/components/grid/layout/StickyColumn.svelte b/packages/frontend-core/src/components/grid/layout/StickyColumn.svelte index d98c1d3d25..d9b3af9436 100644 --- a/packages/frontend-core/src/components/grid/layout/StickyColumn.svelte +++ b/packages/frontend-core/src/components/grid/layout/StickyColumn.svelte @@ -91,6 +91,7 @@ {cellId} {rowFocused} {rowSelected} + cellSelected={$selectedCells[cellId]} highlighted={rowHovered || rowFocused} rowIdx={row.__idx} topRow={idx === 0} @@ -100,7 +101,6 @@ column={$stickyColumn} contentLines={$contentLines} isSelectingCells={$isSelectingCells} - selectedCells={$selectedCells} /> {/if}
diff --git a/packages/frontend-core/src/components/grid/stores/index.js b/packages/frontend-core/src/components/grid/stores/index.js index cb7f5b1106..8e4414e363 100644 --- a/packages/frontend-core/src/components/grid/stores/index.js +++ b/packages/frontend-core/src/components/grid/stores/index.js @@ -20,7 +20,6 @@ import * as Table from "./datasources/table" import * as ViewV2 from "./datasources/viewV2" import * as NonPlus from "./datasources/nonPlus" import * as Cache from "./cache" -import * as Selection from "./selection" const DependencyOrderedStores = [ Sort, @@ -41,7 +40,6 @@ const DependencyOrderedStores = [ Users, Menu, Pagination, - Selection, Clipboard, Config, Notifications, diff --git a/packages/frontend-core/src/components/grid/stores/selection.js b/packages/frontend-core/src/components/grid/stores/selection.js deleted file mode 100644 index bcbdd50079..0000000000 --- a/packages/frontend-core/src/components/grid/stores/selection.js +++ /dev/null @@ -1,135 +0,0 @@ -import { derived, writable, get } from "svelte/store" -import { getCellID, parseCellID } from "../lib/utils" - -export const createStores = () => { - const cellSelection = writable({ - active: false, - sourceCellId: null, - targetCellId: null, - }) - - return { - cellSelection, - } -} - -export const deriveStores = context => { - const { - cellSelection, - rowLookupMap, - columnLookupMap, - rows, - allVisibleColumns, - } = context - - const isSelectingCells = derived(cellSelection, $cellSelection => { - return $cellSelection.active - }) - - const selectedCells = derived( - [cellSelection, rowLookupMap, columnLookupMap], - ([$cellSelection, $rowLookupMap, $columnLookupMap]) => { - const { sourceCellId, targetCellId } = $cellSelection - if (!sourceCellId || !targetCellId || sourceCellId === targetCellId) { - return {} - } - const $rows = get(rows) - const $allVisibleColumns = get(allVisibleColumns) - - // Get source and target row and column indices - const sourceInfo = parseCellID(sourceCellId) - const targetInfo = parseCellID(targetCellId) - - // Row indices - const sourceRowIndex = $rowLookupMap[sourceInfo.id] - const targetRowIndex = $rowLookupMap[targetInfo.id] - const lowerRowIndex = Math.min(sourceRowIndex, targetRowIndex) - const upperRowIndex = Math.max(sourceRowIndex, targetRowIndex) - - // Column indices - const sourceColIndex = $columnLookupMap[sourceInfo.field] - const targetColIndex = $columnLookupMap[targetInfo.field] - const lowerColIndex = Math.min(sourceColIndex, targetColIndex) - const upperColIndex = Math.max(sourceColIndex, targetColIndex) - - // Build map of all cells inside these bounds - let map = {} - let rowId, colName, cellId - for (let rowIdx = lowerRowIndex; rowIdx <= upperRowIndex; rowIdx++) { - for (let colIdx = lowerColIndex; colIdx <= upperColIndex; colIdx++) { - rowId = $rows[rowIdx]._id - colName = $allVisibleColumns[colIdx].name - cellId = getCellID(rowId, colName) - map[cellId] = { rowIdx, colIdx } - } - } - return map - } - ) - - const selectedCellCount = derived(selectedCells, $selectedCells => { - return Object.keys($selectedCells).length - }) - - return { - isSelectingCells, - selectedCells, - selectedCellCount, - } -} - -export const createActions = context => { - const { cellSelection } = context - - const startCellSelection = sourceCellId => { - cellSelection.set({ - active: true, - sourceCellId, - targetCellId: sourceCellId, - }) - } - - const updateCellSelection = targetCellId => { - cellSelection.update(state => ({ - ...state, - targetCellId, - })) - } - - const stopCellSelection = () => { - cellSelection.update(state => ({ - ...state, - active: false, - })) - } - - const clearCellSelection = () => { - cellSelection.set({ - active: false, - sourceCellId: null, - targetCellId: null, - }) - } - - return { - cellSelection: { - ...cellSelection, - actions: { - start: startCellSelection, - update: updateCellSelection, - stop: stopCellSelection, - clear: clearCellSelection, - }, - }, - } -} - -export const initialise = context => { - const { selectedCellCount, selectedRowCount, selectedRows } = context - - selectedCellCount.subscribe($selectedCellCount => { - if ($selectedCellCount && get(selectedRowCount)) { - selectedRows.set({}) - } - }) -} diff --git a/packages/frontend-core/src/components/grid/stores/ui.js b/packages/frontend-core/src/components/grid/stores/ui.js index 3aeb278c89..f3858b5ede 100644 --- a/packages/frontend-core/src/components/grid/stores/ui.js +++ b/packages/frontend-core/src/components/grid/stores/ui.js @@ -7,7 +7,7 @@ import { MediumRowHeight, NewRowID, } from "../lib/constants" -import { parseCellID } from "../lib/utils" +import { getCellID, parseCellID } from "../lib/utils" export const createStores = context => { const { props } = context @@ -22,6 +22,11 @@ export const createStores = context => { const keyboardBlocked = writable(false) const isDragging = writable(false) const buttonColumnWidth = writable(0) + const cellSelection = writable({ + active: false, + sourceCellId: null, + targetCellId: null, + }) return { focusedCellId, @@ -35,6 +40,7 @@ export const createStores = context => { isDragging, buttonColumnWidth, selectedRows, + cellSelection, } } @@ -47,6 +53,9 @@ export const deriveStores = context => { stickyColumn, width, selectedRows, + cellSelection, + columnLookupMap, + allVisibleColumns, } = context // Derive the current focused row ID @@ -89,12 +98,67 @@ export const deriveStores = context => { return Object.keys($selectedRows).length }) + // Derive whether or not we're actively selecting cells + const isSelectingCells = derived(cellSelection, $cellSelection => { + return $cellSelection.active + }) + + // Derive the full extent of all selected cells + const selectedCells = derived( + [cellSelection, rowLookupMap, columnLookupMap], + ([$cellSelection, $rowLookupMap, $columnLookupMap]) => { + const { sourceCellId, targetCellId } = $cellSelection + if (!sourceCellId || !targetCellId || sourceCellId === targetCellId) { + return {} + } + const $rows = get(rows) + const $allVisibleColumns = get(allVisibleColumns) + + // Get source and target row and column indices + const sourceInfo = parseCellID(sourceCellId) + const targetInfo = parseCellID(targetCellId) + + // Row indices + const sourceRowIndex = $rowLookupMap[sourceInfo.id] + const targetRowIndex = $rowLookupMap[targetInfo.id] + const lowerRowIndex = Math.min(sourceRowIndex, targetRowIndex) + const upperRowIndex = Math.max(sourceRowIndex, targetRowIndex) + + // Column indices + const sourceColIndex = $columnLookupMap[sourceInfo.field] + const targetColIndex = $columnLookupMap[targetInfo.field] + const lowerColIndex = Math.min(sourceColIndex, targetColIndex) + const upperColIndex = Math.max(sourceColIndex, targetColIndex) + + // Build map of all cells inside these bounds + let map = {} + let rowId, colName, cellId + for (let rowIdx = lowerRowIndex; rowIdx <= upperRowIndex; rowIdx++) { + for (let colIdx = lowerColIndex; colIdx <= upperColIndex; colIdx++) { + rowId = $rows[rowIdx]._id + colName = $allVisibleColumns[colIdx].name + cellId = getCellID(rowId, colName) + map[cellId] = { rowIdx, colIdx } + } + } + return map + } + ) + + // Derive the count of the selected cells + const selectedCellCount = derived(selectedCells, $selectedCells => { + return Object.keys($selectedCells).length + }) + return { focusedRowId, focusedRow, contentLines, compact, selectedRowCount, + isSelectingCells, + selectedCells, + selectedCellCount, } } @@ -106,6 +170,8 @@ export const createActions = context => { rowLookupMap, rows, selectedRowCount, + cellSelection, + selectedCells, } = context // Keep the last selected index to use with bulk selection let lastSelectedIndex = null @@ -114,6 +180,7 @@ export const createActions = context => { const blur = () => { focusedCellId.set(null) hoveredRowId.set(null) + clearCellSelection() } // Toggles whether a certain row ID is selected or not @@ -159,6 +226,36 @@ export const createActions = context => { }) } + const startCellSelection = sourceCellId => { + cellSelection.set({ + active: true, + sourceCellId, + targetCellId: sourceCellId, + }) + } + + const updateCellSelection = targetCellId => { + cellSelection.update(state => ({ + ...state, + targetCellId, + })) + } + + const stopCellSelection = () => { + cellSelection.update(state => ({ + ...state, + active: false, + })) + } + + const clearCellSelection = () => { + cellSelection.set({ + active: false, + sourceCellId: null, + targetCellId: null, + }) + } + return { ui: { actions: { @@ -172,6 +269,15 @@ export const createActions = context => { bulkSelectRows, }, }, + selectedCells: { + ...selectedCells, + actions: { + start: startCellSelection, + update: updateCellSelection, + stop: stopCellSelection, + clear: clearCellSelection, + }, + }, } } @@ -189,8 +295,8 @@ export const initialise = context => { fixedRowHeight, selectedRowCount, menu, - cellSelection, selectedCellCount, + selectedCells, } = context // Ensure we clear invalid rows from state if they disappear @@ -254,7 +360,7 @@ export const initialise = context => { // Clear cell selection when focusing a cell if (id && get(selectedCellCount)) { - cellSelection.actions.clear() + selectedCells.actions.clear() } // Close the menu if it was open @@ -284,8 +390,15 @@ export const initialise = context => { focusedCellId.set(null) } if (get(selectedCellCount)) { - cellSelection.actions.clear() + selectedCells.actions.clear() } } }) + + // Clear selected rows when selecting cells + selectedCellCount.subscribe($selectedCellCount => { + if ($selectedCellCount && get(selectedRowCount)) { + selectedRows.set({}) + } + }) } From d4d63c611538b39557c11cf15cb15c78adb5c07e Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Sat, 22 Jun 2024 19:28:52 +0100 Subject: [PATCH 17/83] Simplify new paste logic --- .../src/components/grid/cells/DataCell.svelte | 15 +- .../grid/controls/ClipboardHandler.svelte | 2 +- .../src/components/grid/layout/GridRow.svelte | 8 +- .../grid/layout/StickyColumn.svelte | 5 +- .../src/components/grid/stores/clipboard.js | 135 +++++++----------- .../src/components/grid/stores/index.js | 2 +- .../src/components/grid/stores/menu.js | 4 +- .../src/components/grid/stores/ui.js | 37 +++-- .../src/components/grid/stores/users.js | 4 +- 9 files changed, 98 insertions(+), 114 deletions(-) diff --git a/packages/frontend-core/src/components/grid/cells/DataCell.svelte b/packages/frontend-core/src/components/grid/cells/DataCell.svelte index 800d612824..845f919e4a 100644 --- a/packages/frontend-core/src/components/grid/cells/DataCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/DataCell.svelte @@ -86,17 +86,18 @@ if (e.button !== 0) { return } - // focusedCellId.set(cellId) - selectedCells.actions.start(cellId) + selectedCells.actions.startSelecting(cellId) } - const updateSelection = e => { - focusedCellId.set(null) - selectedCells.actions.update(cellId) + const updateSelection = () => { + if ($focusedCellId) { + focusedCellId.set(null) + } + selectedCells.actions.updateTarget(cellId) } - const stopSelection = e => { - selectedCells.actions.stop() + const stopSelection = () => { + selectedCells.actions.stopSelecting() } diff --git a/packages/frontend-core/src/components/grid/controls/ClipboardHandler.svelte b/packages/frontend-core/src/components/grid/controls/ClipboardHandler.svelte index 87b881e8ad..5ae120d47a 100644 --- a/packages/frontend-core/src/components/grid/controls/ClipboardHandler.svelte +++ b/packages/frontend-core/src/components/grid/controls/ClipboardHandler.svelte @@ -40,6 +40,6 @@ onConfirm={clipboard.actions.paste} size="M" > - Are you sure you want to paste values into {$selectedCellCount} cells? + Are you sure you want to paste? This will update multiple values. diff --git a/packages/frontend-core/src/components/grid/layout/GridRow.svelte b/packages/frontend-core/src/components/grid/layout/GridRow.svelte index a01d9de7df..c51d847d9c 100644 --- a/packages/frontend-core/src/components/grid/layout/GridRow.svelte +++ b/packages/frontend-core/src/components/grid/layout/GridRow.svelte @@ -12,15 +12,15 @@ selectedRows, visibleColumns, hoveredRowId, - selectedCellMap, focusedRow, contentLines, isDragging, dispatch, rows, columnRenderMap, + userCellMap, isSelectingCells, - selectedCells, + selectedCellMap, selectedCellCount, } = getContext("grid") @@ -48,12 +48,12 @@ {row} {rowFocused} {rowSelected} - cellSelected={$selectedCells[cellId]} + cellSelected={$selectedCellMap[cellId]} highlighted={rowHovered || rowFocused || reorderSource === column.name} rowIdx={row.__idx} topRow={top} focused={$focusedCellId === cellId} - selectedUser={$selectedCellMap[cellId]} + selectedUser={$userCellMap[cellId]} width={column.width} contentLines={$contentLines} hidden={!$columnRenderMap[column.name]} diff --git a/packages/frontend-core/src/components/grid/layout/StickyColumn.svelte b/packages/frontend-core/src/components/grid/layout/StickyColumn.svelte index d9b3af9436..7fb9906635 100644 --- a/packages/frontend-core/src/components/grid/layout/StickyColumn.svelte +++ b/packages/frontend-core/src/components/grid/layout/StickyColumn.svelte @@ -19,6 +19,7 @@ hoveredRowId, config, selectedCellMap, + userCellMap, focusedRow, scrollLeft, dispatch, @@ -91,12 +92,12 @@ {cellId} {rowFocused} {rowSelected} - cellSelected={$selectedCells[cellId]} + cellSelected={$selectedCellMap[cellId]} highlighted={rowHovered || rowFocused} rowIdx={row.__idx} topRow={idx === 0} focused={$focusedCellId === cellId} - selectedUser={$selectedCellMap[cellId]} + selectedUser={$userCellMap[cellId]} width={$stickyColumn.width} column={$stickyColumn} contentLines={$contentLines} diff --git a/packages/frontend-core/src/components/grid/stores/clipboard.js b/packages/frontend-core/src/components/grid/stores/clipboard.js index 17b39c134b..638e9414ea 100644 --- a/packages/frontend-core/src/components/grid/stores/clipboard.js +++ b/packages/frontend-core/src/components/grid/stores/clipboard.js @@ -13,22 +13,25 @@ export const createStores = () => { } export const deriveStores = context => { - const { clipboard, focusedCellAPI, selectedCellCount } = context + const { clipboard, focusedCellAPI, selectedCellCount, config } = context // Derive whether or not we're able to copy - const copyAllowed = derived(focusedCellAPI, $focusedCellAPI => { - return $focusedCellAPI != null - }) + const copyAllowed = derived( + [focusedCellAPI, selectedCellCount], + ([$focusedCellAPI, $selectedCellCount]) => { + return $focusedCellAPI || $selectedCellCount + } + ) // Derive whether or not we're able to paste const pasteAllowed = derived( - [clipboard, focusedCellAPI, selectedCellCount], - ([$clipboard, $focusedCellAPI, $selectedCellCount]) => { - if ($clipboard.value == null || !$focusedCellAPI) { + [clipboard, focusedCellAPI, selectedCellCount, config], + ([$clipboard, $focusedCellAPI, $selectedCellCount, $config]) => { + if ($clipboard.value == null || !$config.canEditRows) { return false } - // Prevent pasting into a single cell, if we have a single cell value and - // this cell is readonly + + // Prevent single-single pasting if the cell is readonly const multiCellPaste = $selectedCellCount > 1 if ( !$clipboard.multiCellCopy && @@ -37,7 +40,8 @@ export const deriveStores = context => { ) { return false } - return true + + return $focusedCellAPI || $selectedCellCount } ) @@ -54,10 +58,10 @@ export const createActions = context => { copyAllowed, pasteAllowed, selectedCells, + selectedCellCount, rowLookupMap, rowChangeCache, rows, - columnLookupMap, } = context // Copies the currently selected value (or values) @@ -65,52 +69,31 @@ export const createActions = context => { if (!get(copyAllowed)) { return } - const cellIds = Object.keys(get(selectedCells)) + const $selectedCells = get(selectedCells) const $focusedCellAPI = get(focusedCellAPI) - const multiCellCopy = cellIds.length > 1 + const $selectedCellCount = get(selectedCellCount) + const multiCellCopy = $selectedCellCount > 1 // Multiple values to copy if (multiCellCopy) { const $rowLookupMap = get(rowLookupMap) const $rowChangeCache = get(rowChangeCache) const $rows = get(rows) - const $columnLookupMap = get(columnLookupMap) - // Go through each selected cell and group all selected cell values by - // their row ID. Order is important for pasting, so we store the index of - // both rows and values. - let map = {} - for (let cellId of cellIds) { - const { id, field } = parseCellID(cellId) - const index = $rowLookupMap[id] - if (!map[id]) { - map[id] = { - order: index, - values: [], - } - } - const row = { - ...$rows[index], - ...$rowChangeCache[id], - } - const columnIndex = $columnLookupMap[field] - map[id].values.push({ - value: row[field], - order: columnIndex, - }) - } - - // Sort rows by order + // Extract value of each selected cell let value = [] - const sortedRowValues = Object.values(map) - .toSorted((a, b) => a.order - b.order) - .map(x => x.values) - - // Sort all values in each row by order - for (let rowValues of sortedRowValues) { - value.push( - rowValues.toSorted((a, b) => a.order - b.order).map(x => x.value) - ) + for (let row of $selectedCells) { + const rowValues = [] + for (let cellId of row) { + const { id, field } = parseCellID(cellId) + const rowIndex = $rowLookupMap[id] + const row = { + ...$rows[rowIndex], + ...$rowChangeCache[id], + } + rowValues.push(row[field]) + } + value.push(rowValues) } // Update state @@ -141,42 +124,26 @@ export const createActions = context => { if (!get(pasteAllowed)) { return } - const $clipboard = get(clipboard) + const { value, multiCellCopy } = get(clipboard) const $focusedCellAPI = get(focusedCellAPI) - if ($clipboard.value == null || !$focusedCellAPI) { - return - } - - // Check if we're pasting into one or more cells const $selectedCells = get(selectedCells) - const cellIds = Object.keys($selectedCells) - const multiCellPaste = cellIds.length > 1 + const $selectedCellCount = get(selectedCellCount) + const multiCellPaste = $selectedCellCount > 1 - if ($clipboard.multiCellCopy) { + // Choose paste strategy + if (multiCellCopy) { if (multiCellPaste) { // Multi to multi (only paste selected cells) - const value = $clipboard.value + // Find the extent at which we can paste + const rowExtent = Math.min(value.length, $selectedCells.length) + const colExtent = Math.min(value[0].length, $selectedCells[0].length) - // Find the top left index so we can find the relative offset for each - // cell - let rowIndices = [] - let columnIndices = [] - for (let cellId of cellIds) { - rowIndices.push($selectedCells[cellId].rowIdx) - columnIndices.push($selectedCells[cellId].colIdx) - } - const minRowIdx = Math.min(...rowIndices) - const minColIdx = Math.min(...columnIndices) - - // Build change map of values to patch + // Build change map let changeMap = {} - const $rowLookupMap = get(rowLookupMap) - const $columnLookupMap = get(columnLookupMap) - for (let cellId of cellIds) { - const { id, field } = parseCellID(cellId) - const rowIdx = $rowLookupMap[id] - minRowIdx - const colIdx = $columnLookupMap[field] - minColIdx - if (colIdx in (value[rowIdx] || [])) { + for (let rowIdx = 0; rowIdx < rowExtent; rowIdx++) { + for (let colIdx = 0; colIdx < colExtent; colIdx++) { + const cellId = $selectedCells[rowIdx][colIdx] + const { id, field } = parseCellID(cellId) if (!changeMap[id]) { changeMap[id] = {} } @@ -192,17 +159,19 @@ export const createActions = context => { if (multiCellPaste) { // Single to multi (duplicate value in all selected cells) let changeMap = {} - for (let cellId of cellIds) { - const { id, field } = parseCellID(cellId) - if (!changeMap[id]) { - changeMap[id] = {} + for (let row of $selectedCells) { + for (let cellId of row) { + const { id, field } = parseCellID(cellId) + if (!changeMap[id]) { + changeMap[id] = {} + } + changeMap[id][field] = value } - changeMap[id][field] = $clipboard.value } await rows.actions.bulkUpdate(changeMap) } else { // Single to single - $focusedCellAPI.setValue($clipboard.value) + $focusedCellAPI.setValue(value) } } } diff --git a/packages/frontend-core/src/components/grid/stores/index.js b/packages/frontend-core/src/components/grid/stores/index.js index 8e4414e363..eb966d2559 100644 --- a/packages/frontend-core/src/components/grid/stores/index.js +++ b/packages/frontend-core/src/components/grid/stores/index.js @@ -40,8 +40,8 @@ const DependencyOrderedStores = [ Users, Menu, Pagination, - Clipboard, Config, + Clipboard, Notifications, Cache, ] diff --git a/packages/frontend-core/src/components/grid/stores/menu.js b/packages/frontend-core/src/components/grid/stores/menu.js index 295604b275..d9e45b19a1 100644 --- a/packages/frontend-core/src/components/grid/stores/menu.js +++ b/packages/frontend-core/src/components/grid/stores/menu.js @@ -21,7 +21,7 @@ export const createActions = context => { gridID, selectedRows, selectedRowCount, - selectedCells, + selectedCellMap, selectedCellCount, } = context @@ -52,7 +52,7 @@ export const createActions = context => { // Check if there are multiple cells selected, and if this is one of them let multiCellMode = false if (!multiRowMode && get(selectedCellCount) > 1) { - if (get(selectedCells)[cellId]) { + if (get(selectedCellMap)[cellId]) { multiCellMode = true } } diff --git a/packages/frontend-core/src/components/grid/stores/ui.js b/packages/frontend-core/src/components/grid/stores/ui.js index f3858b5ede..30876b9a50 100644 --- a/packages/frontend-core/src/components/grid/stores/ui.js +++ b/packages/frontend-core/src/components/grid/stores/ui.js @@ -109,7 +109,7 @@ export const deriveStores = context => { ([$cellSelection, $rowLookupMap, $columnLookupMap]) => { const { sourceCellId, targetCellId } = $cellSelection if (!sourceCellId || !targetCellId || sourceCellId === targetCellId) { - return {} + return [] } const $rows = get(rows) const $allVisibleColumns = get(allVisibleColumns) @@ -130,24 +130,36 @@ export const deriveStores = context => { const lowerColIndex = Math.min(sourceColIndex, targetColIndex) const upperColIndex = Math.max(sourceColIndex, targetColIndex) - // Build map of all cells inside these bounds - let map = {} - let rowId, colName, cellId + // Build 2 dimensional array of all cells inside these bounds + let cells = [] + let rowId, colName for (let rowIdx = lowerRowIndex; rowIdx <= upperRowIndex; rowIdx++) { + let rowCells = [] for (let colIdx = lowerColIndex; colIdx <= upperColIndex; colIdx++) { rowId = $rows[rowIdx]._id colName = $allVisibleColumns[colIdx].name - cellId = getCellID(rowId, colName) - map[cellId] = { rowIdx, colIdx } + rowCells.push(getCellID(rowId, colName)) } + cells.push(rowCells) } - return map + return cells } ) + // Derive a quick lookup map of the selected cells + const selectedCellMap = derived(selectedCells, $selectedCells => { + let map = {} + for (let row of $selectedCells) { + for (let cell of row) { + map[cell] = true + } + } + return map + }) + // Derive the count of the selected cells - const selectedCellCount = derived(selectedCells, $selectedCells => { - return Object.keys($selectedCells).length + const selectedCellCount = derived(selectedCellMap, $selectedCellMap => { + return Object.keys($selectedCellMap).length }) return { @@ -158,6 +170,7 @@ export const deriveStores = context => { selectedRowCount, isSelectingCells, selectedCells, + selectedCellMap, selectedCellCount, } } @@ -272,9 +285,9 @@ export const createActions = context => { selectedCells: { ...selectedCells, actions: { - start: startCellSelection, - update: updateCellSelection, - stop: stopCellSelection, + startSelecting: startCellSelection, + updateTarget: updateCellSelection, + stopSelecting: stopCellSelection, clear: clearCellSelection, }, }, diff --git a/packages/frontend-core/src/components/grid/stores/users.js b/packages/frontend-core/src/components/grid/stores/users.js index 7dd7a69592..64c1e27835 100644 --- a/packages/frontend-core/src/components/grid/stores/users.js +++ b/packages/frontend-core/src/components/grid/stores/users.js @@ -25,7 +25,7 @@ export const deriveStores = context => { // Generate a lookup map of cell ID to the user that has it selected, to make // lookups inside cells extremely fast - const selectedCellMap = derived( + const userCellMap = derived( [users, focusedCellId], ([$users, $focusedCellId]) => { let map = {} @@ -40,7 +40,7 @@ export const deriveStores = context => { ) return { - selectedCellMap, + userCellMap, } } From 878aa35335b55ea60daecbd6630153c41552085a Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Sun, 23 Jun 2024 10:34:23 +0100 Subject: [PATCH 18/83] Add support for bulk selecting cells via shift key --- .../src/components/grid/cells/DataCell.svelte | 19 +++++++++++++++++-- .../src/components/grid/stores/ui.js | 9 +++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/frontend-core/src/components/grid/cells/DataCell.svelte b/packages/frontend-core/src/components/grid/cells/DataCell.svelte index 845f919e4a..e9293f95b2 100644 --- a/packages/frontend-core/src/components/grid/cells/DataCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/DataCell.svelte @@ -12,6 +12,7 @@ config, validation, selectedCells, + selectedCellCount, } = getContext("grid") export let highlighted @@ -83,7 +84,7 @@ } const startSelection = e => { - if (e.button !== 0) { + if (e.button !== 0 || e.shiftKey) { return } selectedCells.actions.startSelecting(cellId) @@ -99,6 +100,20 @@ const stopSelection = () => { selectedCells.actions.stopSelecting() } + + const handleClick = e => { + if (e.shiftKey && $focusedCellId) { + // If we have a focused cell, select the range from that cell to here + selectedCells.actions.setRange($focusedCellId, cellId) + focusedCellId.set(null) + } else if (e.shiftKey && $selectedCellCount) { + // If we already have a selected range of cell, update it + selectedCells.actions.updateTarget(cellId) + } else { + // Otherwise just select this cell + focusedCellId.set(cellId) + } + } focusedCellId.set(cellId)} + on:click={handleClick} width={column.width} > { })) } + const setCellSelectionRange = (source, target) => { + cellSelection.set({ + active: false, + sourceCellId: source, + targetCellId: target, + }) + } + const clearCellSelection = () => { cellSelection.set({ active: false, @@ -288,6 +296,7 @@ export const createActions = context => { startSelecting: startCellSelection, updateTarget: updateCellSelection, stopSelecting: stopCellSelection, + setRange: setCellSelectionRange, clear: clearCellSelection, }, }, From 9c360a1f027675810a559617919f31c610dbb4ab Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Sun, 23 Jun 2024 13:10:20 +0100 Subject: [PATCH 19/83] Handle edge case when pasting without releasing mouse --- .../frontend-core/src/components/grid/cells/DataCell.svelte | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/frontend-core/src/components/grid/cells/DataCell.svelte b/packages/frontend-core/src/components/grid/cells/DataCell.svelte index e9293f95b2..3e9d6b6b05 100644 --- a/packages/frontend-core/src/components/grid/cells/DataCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/DataCell.svelte @@ -90,7 +90,11 @@ selectedCells.actions.startSelecting(cellId) } - const updateSelection = () => { + const updateSelection = e => { + if (e.buttons !== 1) { + selectedCells.actions.stopSelecting() + return + } if ($focusedCellId) { focusedCellId.set(null) } From 70fd64343199f0f91081cabd1d6c25b19d26a780 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Sun, 23 Jun 2024 13:35:45 +0100 Subject: [PATCH 20/83] Simplify and improve bulk pasting logic --- .../src/components/grid/cells/DataCell.svelte | 4 - .../src/components/grid/stores/clipboard.js | 102 ++++++++++++------ .../src/components/grid/stores/ui.js | 14 ++- 3 files changed, 76 insertions(+), 44 deletions(-) diff --git a/packages/frontend-core/src/components/grid/cells/DataCell.svelte b/packages/frontend-core/src/components/grid/cells/DataCell.svelte index 3e9d6b6b05..6619bbb083 100644 --- a/packages/frontend-core/src/components/grid/cells/DataCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/DataCell.svelte @@ -95,9 +95,6 @@ selectedCells.actions.stopSelecting() return } - if ($focusedCellId) { - focusedCellId.set(null) - } selectedCells.actions.updateTarget(cellId) } @@ -109,7 +106,6 @@ if (e.shiftKey && $focusedCellId) { // If we have a focused cell, select the range from that cell to here selectedCells.actions.setRange($focusedCellId, cellId) - focusedCellId.set(null) } else if (e.shiftKey && $selectedCellCount) { // If we already have a selected range of cell, update it selectedCells.actions.updateTarget(cellId) diff --git a/packages/frontend-core/src/components/grid/stores/clipboard.js b/packages/frontend-core/src/components/grid/stores/clipboard.js index 638e9414ea..ce0df1ed59 100644 --- a/packages/frontend-core/src/components/grid/stores/clipboard.js +++ b/packages/frontend-core/src/components/grid/stores/clipboard.js @@ -1,6 +1,6 @@ import { derived, writable, get } from "svelte/store" import { Helpers } from "@budibase/bbui" -import { parseCellID } from "../lib/utils" +import { parseCellID, getCellID } from "../lib/utils" export const createStores = () => { const clipboard = writable({ @@ -62,6 +62,9 @@ export const createActions = context => { rowLookupMap, rowChangeCache, rows, + focusedCellId, + columnLookupMap, + allVisibleColumns, } = context // Copies the currently selected value (or values) @@ -125,57 +128,86 @@ export const createActions = context => { return } const { value, multiCellCopy } = get(clipboard) - const $focusedCellAPI = get(focusedCellAPI) - const $selectedCells = get(selectedCells) - const $selectedCellCount = get(selectedCellCount) - const multiCellPaste = $selectedCellCount > 1 + const multiCellPaste = get(selectedCellCount) > 1 // Choose paste strategy if (multiCellCopy) { if (multiCellPaste) { // Multi to multi (only paste selected cells) - // Find the extent at which we can paste - const rowExtent = Math.min(value.length, $selectedCells.length) - const colExtent = Math.min(value[0].length, $selectedCells[0].length) - - // Build change map - let changeMap = {} - for (let rowIdx = 0; rowIdx < rowExtent; rowIdx++) { - for (let colIdx = 0; colIdx < colExtent; colIdx++) { - const cellId = $selectedCells[rowIdx][colIdx] - const { id, field } = parseCellID(cellId) - if (!changeMap[id]) { - changeMap[id] = {} - } - changeMap[id][field] = value[rowIdx][colIdx] - } - } - await rows.actions.bulkUpdate(changeMap) + await pasteIntoSelectedCells(value) } else { // Multi to single (expand to paste all values) - // TODO + // Get indices of focused cell + const $focusedCellId = get(focusedCellId) + const { id, field } = parseCellID($focusedCellId) + const $rowLookupMap = get(rowLookupMap) + const $columnLookupMap = get(columnLookupMap) + const rowIdx = $rowLookupMap[id] + const colIdx = $columnLookupMap[field] + + // Get limits of how many rows and columns we're able to paste into + const $rows = get(rows) + const $allVisibleColumns = get(allVisibleColumns) + const colCount = $allVisibleColumns.length + const rowCount = $rows.length + const selectedRows = value.length + const selectedColumns = value[0].length + const rowExtent = Math.min(selectedRows, rowCount - rowIdx) - 1 + const colExtent = Math.min(selectedColumns, colCount - colIdx) - 1 + + // Get the target cell ID (bottom right of our pastable extent) + const targetRowId = $rows[rowIdx + rowExtent]._id + const targetColName = $allVisibleColumns[colIdx + colExtent].name + const targetCellId = getCellID(targetRowId, targetColName) + + // Paste into target cell range + if (targetCellId === $focusedCellId) { + // Single cell edge case + get(focusedCellAPI).setValue(value[0][0]) + } else { + // Select the new cells to paste into, then paste + selectedCells.actions.updateTarget(targetCellId) + await pasteIntoSelectedCells(value) + } } } else { if (multiCellPaste) { // Single to multi (duplicate value in all selected cells) - let changeMap = {} - for (let row of $selectedCells) { - for (let cellId of row) { - const { id, field } = parseCellID(cellId) - if (!changeMap[id]) { - changeMap[id] = {} - } - changeMap[id][field] = value - } - } - await rows.actions.bulkUpdate(changeMap) + const $selectedCells = get(selectedCells) + const pastableValue = $selectedCells.map(row => { + return row.map(() => value) + }) + await pasteIntoSelectedCells(pastableValue) } else { // Single to single - $focusedCellAPI.setValue(value) + get(focusedCellAPI).setValue(value) } } } + // Paste the specified value into the currently selected cells + const pasteIntoSelectedCells = async value => { + const $selectedCells = get(selectedCells) + + // Find the extent at which we can paste + const rowExtent = Math.min(value.length, $selectedCells.length) + const colExtent = Math.min(value[0].length, $selectedCells[0].length) + + // Build change map + let changeMap = {} + for (let rowIdx = 0; rowIdx < rowExtent; rowIdx++) { + for (let colIdx = 0; colIdx < colExtent; colIdx++) { + const cellId = $selectedCells[rowIdx][colIdx] + const { id, field } = parseCellID(cellId) + if (!changeMap[id]) { + changeMap[id] = {} + } + changeMap[id][field] = value[rowIdx][colIdx] + } + } + await rows.actions.bulkUpdate(changeMap) + } + return { clipboard: { ...clipboard, diff --git a/packages/frontend-core/src/components/grid/stores/ui.js b/packages/frontend-core/src/components/grid/stores/ui.js index bd3e973a5c..10a17a372a 100644 --- a/packages/frontend-core/src/components/grid/stores/ui.js +++ b/packages/frontend-core/src/components/grid/stores/ui.js @@ -217,9 +217,8 @@ export const createActions = context => { toggleSelectedRow(id) return } - // There should always be a last selected index if (lastSelectedIndex == null) { - throw "NO LAST SELECTED INDEX" + return } const thisIndex = get(rowLookupMap)[id] @@ -417,10 +416,15 @@ export const initialise = context => { } }) - // Clear selected rows when selecting cells + // Clear state when selecting cells selectedCellCount.subscribe($selectedCellCount => { - if ($selectedCellCount && get(selectedRowCount)) { - selectedRows.set({}) + if ($selectedCellCount) { + if (get(selectedRowCount)) { + selectedRows.set({}) + } + if (get(focusedCellId)) { + focusedCellId.set(null) + } } }) } From a3be0f1cd16a7440861223895eaec118981b8fb9 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Sun, 23 Jun 2024 13:39:29 +0100 Subject: [PATCH 21/83] Add comments --- .../src/components/grid/stores/clipboard.js | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/frontend-core/src/components/grid/stores/clipboard.js b/packages/frontend-core/src/components/grid/stores/clipboard.js index ce0df1ed59..2c779c855d 100644 --- a/packages/frontend-core/src/components/grid/stores/clipboard.js +++ b/packages/frontend-core/src/components/grid/stores/clipboard.js @@ -83,7 +83,7 @@ export const createActions = context => { const $rowChangeCache = get(rowChangeCache) const $rows = get(rows) - // Extract value of each selected cell + // Extract value of each selected cell, accounting for the change cache let value = [] for (let row of $selectedCells) { const rowValues = [] @@ -133,10 +133,10 @@ export const createActions = context => { // Choose paste strategy if (multiCellCopy) { if (multiCellPaste) { - // Multi to multi (only paste selected cells) + // Multi to multi - try pasting into all selected cells await pasteIntoSelectedCells(value) } else { - // Multi to single (expand to paste all values) + // Multi to single - expand to paste all values // Get indices of focused cell const $focusedCellId = get(focusedCellId) const { id, field } = parseCellID($focusedCellId) @@ -172,14 +172,11 @@ export const createActions = context => { } } else { if (multiCellPaste) { - // Single to multi (duplicate value in all selected cells) - const $selectedCells = get(selectedCells) - const pastableValue = $selectedCells.map(row => { - return row.map(() => value) - }) - await pasteIntoSelectedCells(pastableValue) + // Single to multi - duplicate value to all selected cells + const newValue = get(selectedCells).map(row => row.map(() => value)) + await pasteIntoSelectedCells(newValue) } else { - // Single to single + // Single to single - just update the cell's value get(focusedCellAPI).setValue(value) } } From 3a4b3e8c4266b1159d610684fb8955ff53057587 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Sun, 23 Jun 2024 14:01:55 +0100 Subject: [PATCH 22/83] Make naming more consistent and impove multi-row pasting --- .../src/components/grid/lib/utils.js | 8 ++-- .../grid/overlays/KeyboardManager.svelte | 12 +----- .../src/components/grid/stores/clipboard.js | 37 ++++++++++++++----- .../src/components/grid/stores/menu.js | 2 +- .../src/components/grid/stores/rows.js | 2 +- .../src/components/grid/stores/ui.js | 8 ++-- .../src/components/grid/stores/validation.js | 2 +- 7 files changed, 40 insertions(+), 31 deletions(-) diff --git a/packages/frontend-core/src/components/grid/lib/utils.js b/packages/frontend-core/src/components/grid/lib/utils.js index b921c39951..1988b66cc2 100644 --- a/packages/frontend-core/src/components/grid/lib/utils.js +++ b/packages/frontend-core/src/components/grid/lib/utils.js @@ -1,17 +1,17 @@ import { helpers } from "@budibase/shared-core" import { TypeIconMap } from "../../../constants" -// we can't use "-" for joining the ID/field, as this can be present in the ID or column name -// using something very unusual to avoid this problem +// We can't use "-" as a separator as this can be present in the ID +// or column name, so we use something very unusual to avoid this problem const JOINING_CHARACTER = "‽‽" export const parseCellID = cellId => { if (!cellId) { - return { id: undefined, field: undefined } + return { rowId: undefined, field: undefined } } const parts = cellId.split(JOINING_CHARACTER) const field = parts.pop() - return { id: parts.join(JOINING_CHARACTER), field } + return { rowId: parts.join(JOINING_CHARACTER), field } } export const getCellID = (rowId, fieldName) => { diff --git a/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte b/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte index 0662fdf6a1..98b4d6df25 100644 --- a/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte +++ b/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte @@ -158,7 +158,7 @@ return } const cols = $visibleColumns - const { id, field: columnName } = parseCellID($focusedCellId) + const { rowId, field: columnName } = parseCellID($focusedCellId) let newColumnName if (columnName === $stickyColumn?.name) { const index = delta - 1 @@ -172,7 +172,7 @@ } } if (newColumnName) { - $focusedCellId = getCellID(id, newColumnName) + $focusedCellId = getCellID(rowId, newColumnName) } } @@ -227,14 +227,6 @@ } } - const toggleSelectRow = () => { - const id = $focusedRow?._id - if (!id || id === NewRowID) { - return - } - selectedRows.actions.toggleRow(id) - } - onMount(() => { document.addEventListener("keydown", handleKeyDown) return () => { diff --git a/packages/frontend-core/src/components/grid/stores/clipboard.js b/packages/frontend-core/src/components/grid/stores/clipboard.js index 2c779c855d..793abc9ad8 100644 --- a/packages/frontend-core/src/components/grid/stores/clipboard.js +++ b/packages/frontend-core/src/components/grid/stores/clipboard.js @@ -88,11 +88,11 @@ export const createActions = context => { for (let row of $selectedCells) { const rowValues = [] for (let cellId of row) { - const { id, field } = parseCellID(cellId) - const rowIndex = $rowLookupMap[id] + const { rowId, field } = parseCellID(cellId) + const rowIndex = $rowLookupMap[rowId] const row = { ...$rows[rowIndex], - ...$rowChangeCache[id], + ...$rowChangeCache[rowId], } rowValues.push(row[field]) } @@ -134,15 +134,32 @@ export const createActions = context => { if (multiCellCopy) { if (multiCellPaste) { // Multi to multi - try pasting into all selected cells - await pasteIntoSelectedCells(value) + let newValue = value + + // If we are pasting into more rows than we copied, but the number of + // columns match, then repeat the copied values as required + const $selectedCells = get(selectedCells) + const selectedRows = $selectedCells.length + const selectedColumns = $selectedCells[0].length + const copiedRows = value.length + const copiedColumns = value[0].length + if (selectedRows > copiedRows && selectedColumns === copiedColumns) { + newValue = [] + for (let i = 0; i < selectedRows; i++) { + newValue.push(value[i % copiedRows]) + } + } + + // Paste the new value + await pasteIntoSelectedCells(newValue) } else { // Multi to single - expand to paste all values // Get indices of focused cell const $focusedCellId = get(focusedCellId) - const { id, field } = parseCellID($focusedCellId) + const { rowId, field } = parseCellID($focusedCellId) const $rowLookupMap = get(rowLookupMap) const $columnLookupMap = get(columnLookupMap) - const rowIdx = $rowLookupMap[id] + const rowIdx = $rowLookupMap[rowId] const colIdx = $columnLookupMap[field] // Get limits of how many rows and columns we're able to paste into @@ -195,11 +212,11 @@ export const createActions = context => { for (let rowIdx = 0; rowIdx < rowExtent; rowIdx++) { for (let colIdx = 0; colIdx < colExtent; colIdx++) { const cellId = $selectedCells[rowIdx][colIdx] - const { id, field } = parseCellID(cellId) - if (!changeMap[id]) { - changeMap[id] = {} + const { rowId, field } = parseCellID(cellId) + if (!changeMap[rowId]) { + changeMap[rowId] = {} } - changeMap[id][field] = value[rowIdx][colIdx] + changeMap[rowId][field] = value[rowIdx][colIdx] } } await rows.actions.bulkUpdate(changeMap) diff --git a/packages/frontend-core/src/components/grid/stores/menu.js b/packages/frontend-core/src/components/grid/stores/menu.js index d9e45b19a1..22bf26fff5 100644 --- a/packages/frontend-core/src/components/grid/stores/menu.js +++ b/packages/frontend-core/src/components/grid/stores/menu.js @@ -43,7 +43,7 @@ export const createActions = context => { // Check if there are multiple rows selected, and if this is one of them let multiRowMode = false if (get(selectedRowCount) > 1) { - const rowId = parseCellID(cellId).id + const { rowId } = parseCellID(cellId) if (get(selectedRows)[rowId]) { multiRowMode = true } diff --git a/packages/frontend-core/src/components/grid/stores/rows.js b/packages/frontend-core/src/components/grid/stores/rows.js index f78a57fe1c..ad8024b403 100644 --- a/packages/frontend-core/src/components/grid/stores/rows.js +++ b/packages/frontend-core/src/components/grid/stores/rows.js @@ -711,7 +711,7 @@ export const initialise = context => { if (!id) { return } - const { id: rowId, field } = parseCellID(id) + const { rowId, field } = parseCellID(id) const hasChanges = field in (get(rowChangeCache)[rowId] || {}) const hasErrors = validation.actions.rowHasErrors(rowId) const isSavingChanges = get(inProgressChanges)[rowId] diff --git a/packages/frontend-core/src/components/grid/stores/ui.js b/packages/frontend-core/src/components/grid/stores/ui.js index 10a17a372a..be56fd8538 100644 --- a/packages/frontend-core/src/components/grid/stores/ui.js +++ b/packages/frontend-core/src/components/grid/stores/ui.js @@ -60,7 +60,7 @@ export const deriveStores = context => { // Derive the current focused row ID const focusedRowId = derived(focusedCellId, $focusedCellId => { - return parseCellID($focusedCellId)?.id + return parseCellID($focusedCellId).rowId }) // Derive the row that contains the selected cell @@ -119,8 +119,8 @@ export const deriveStores = context => { const targetInfo = parseCellID(targetCellId) // Row indices - const sourceRowIndex = $rowLookupMap[sourceInfo.id] - const targetRowIndex = $rowLookupMap[targetInfo.id] + const sourceRowIndex = $rowLookupMap[sourceInfo.rowId] + const targetRowIndex = $rowLookupMap[targetInfo.rowId] const lowerRowIndex = Math.min(sourceRowIndex, targetRowIndex) const upperRowIndex = Math.max(sourceRowIndex, targetRowIndex) @@ -331,7 +331,7 @@ export const initialise = context => { const hasRow = rows.actions.hasRow // Check selected cell - const selectedRowId = parseCellID($focusedCellId)?.id + const selectedRowId = parseCellID($focusedCellId).rowId if (selectedRowId && !hasRow(selectedRowId)) { focusedCellId.set(null) } diff --git a/packages/frontend-core/src/components/grid/stores/validation.js b/packages/frontend-core/src/components/grid/stores/validation.js index 6dd98ffff9..93e67e1d31 100644 --- a/packages/frontend-core/src/components/grid/stores/validation.js +++ b/packages/frontend-core/src/components/grid/stores/validation.js @@ -21,7 +21,7 @@ export const deriveStores = context => { Object.entries($validation).forEach(([key, error]) => { // Extract row ID from all errored cell IDs if (error) { - const rowId = parseCellID(key).id + const { rowId } = parseCellID(key) if (!map[rowId]) { map[rowId] = [] } From 4ec6a22e42780b78ecc06de03e3ec10909eacf23 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Sun, 23 Jun 2024 14:31:34 +0100 Subject: [PATCH 23/83] Add bulk cell selection using shift+keyboard --- .../src/components/grid/cells/DataCell.svelte | 2 +- .../grid/overlays/KeyboardManager.svelte | 126 ++++++++++++++---- .../src/components/grid/stores/ui.js | 19 ++- 3 files changed, 112 insertions(+), 35 deletions(-) diff --git a/packages/frontend-core/src/components/grid/cells/DataCell.svelte b/packages/frontend-core/src/components/grid/cells/DataCell.svelte index 6619bbb083..179be0c90e 100644 --- a/packages/frontend-core/src/components/grid/cells/DataCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/DataCell.svelte @@ -105,7 +105,7 @@ const handleClick = e => { if (e.shiftKey && $focusedCellId) { // If we have a focused cell, select the range from that cell to here - selectedCells.actions.setRange($focusedCellId, cellId) + selectedCells.actions.selectRange($focusedCellId, cellId) } else if (e.shiftKey && $selectedCellCount) { // If we already have a selected range of cell, update it selectedCells.actions.updateTarget(cellId) diff --git a/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte b/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte index 98b4d6df25..1f216e14d4 100644 --- a/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte +++ b/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte @@ -1,14 +1,13 @@ - + - Are you sure you want to delete {selectedRowCount} - row{selectedRowCount === 1 ? "" : "s"}? + Are you sure you want to delete {$selectedRowCount} + row{$selectedRowCount === 1 ? "" : "s"}? + + + + + + Are you sure you want to delete {$selectedCellCount} + cell{$selectedCellCount === 1 ? "" : "s"}? diff --git a/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte b/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte index e74cddd61b..5c189b155b 100644 --- a/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte +++ b/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte @@ -81,6 +81,9 @@ return handle(() => changeFocusedRow(1, e.shiftKey)) case "Escape": return handle(selectedCells.actions.clear) + case "Delete": + case "Backspace": + return handle(() => dispatch("request-bulk-delete")) } } diff --git a/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte b/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte index 99ecc602a4..5e433140d9 100644 --- a/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte +++ b/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte @@ -20,6 +20,7 @@ selectedRowCount, copyAllowed, pasteAllowed, + selectedCellCount, } = getContext("grid") let anchor @@ -92,8 +93,12 @@ > Paste
- {}}> - Delete + dispatch("request-bulk-delete")} + > + Delete {$selectedCellCount} cells {:else} Date: Sun, 23 Jun 2024 19:12:03 +0100 Subject: [PATCH 27/83] Add progress indicator to bulk pasting --- .../bbui/src/ProgressBar/ProgressBar.svelte | 22 ++++-------- .../grid/controls/ClipboardHandler.svelte | 34 +++++++++++++++---- .../src/components/grid/stores/clipboard.js | 12 +++---- .../src/components/grid/stores/rows.js | 7 ++-- 4 files changed, 46 insertions(+), 29 deletions(-) diff --git a/packages/bbui/src/ProgressBar/ProgressBar.svelte b/packages/bbui/src/ProgressBar/ProgressBar.svelte index 9219d068e1..41c06b4dd6 100644 --- a/packages/bbui/src/ProgressBar/ProgressBar.svelte +++ b/packages/bbui/src/ProgressBar/ProgressBar.svelte @@ -1,33 +1,22 @@
- {Math.round($progress)}% + {Math.round(value)}%
{/if}
@@ -51,7 +40,7 @@ class="spectrum-ProgressBar-fill" class:color-green={color === "green"} class:color-red={color === "red"} - style={value || value === 0 ? `width: ${$progress}%` : ""} + style="width: {value}%; --duration: {duration}ms;" />