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