1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-17 18:32:35 +00:00

Multi-Cell Paste

This commit is contained in:
Zuhair Rayyes 2022-05-05 07:42:53 +00:00 committed by Bram Wiepjes
parent 37a27a795a
commit 22ee2f79a5
22 changed files with 456 additions and 185 deletions
changelog.md
premium/web-frontend/modules/baserow_premium/store/view
web-frontend

View file

@ -45,6 +45,7 @@
* Fixed bug where the link row field `link_row_relation_id` could fail when two
simultaneous requests are made.
* Added password protection for publicly shared grids and forms.
* Added multi-cell pasting.
* Made views trashable.
* Fixed bug where a cell value was not reverted when the request to the backend fails.
* **Premium** Added row coloring.

View file

@ -776,9 +776,6 @@ export const actions = {
const currentFieldValue = row[fieldToCallName]
const optimisticFieldValue = fieldType.onRowChange(
row,
field,
value,
oldValue,
fieldToCall,
currentFieldValue
)

View file

@ -24,6 +24,7 @@
"retry": "Wiederholen",
"search": "Suche",
"copy": "Kopieren",
"paste": "Einfügen",
"activate": "Aktivieren",
"deactivate": "Deaktivieren"
},

View file

@ -24,6 +24,7 @@
"retry": "Retry",
"search": "Search",
"copy": "Copy",
"paste": "Paste",
"activate": "Activate",
"deactivate": "Deactivate"
},

View file

@ -20,6 +20,7 @@
:state="undoRedoState"
></UndoRedoNotification>
<CopyingNotification v-if="copying"></CopyingNotification>
<PastingNotification v-if="pasting"></PastingNotification>
<RestoreNotification
v-for="notification in restoreNotifications"
:key="notification.id"
@ -37,6 +38,7 @@ import ConnectingNotification from '@baserow/modules/core/components/notificatio
import FailedConnectingNotification from '@baserow/modules/core/components/notifications/FailedConnectingNotification'
import RestoreNotification from '@baserow/modules/core/components/notifications/RestoreNotification'
import CopyingNotification from '@baserow/modules/core/components/notifications/CopyingNotification'
import PastingNotification from '@baserow/modules/core/components/notifications/PastingNotification'
import AuthorizationErrorNotification from '@baserow/modules/core/components/notifications/AuthorizationErrorNotification'
import UndoRedoNotification from '@baserow/modules/core/components/notifications/UndoRedoNotification'
import { UNDO_REDO_STATES } from '@baserow/modules/core/utils/undoRedoConstants'
@ -49,6 +51,7 @@ export default {
ConnectingNotification,
FailedConnectingNotification,
CopyingNotification,
PastingNotification,
AuthorizationErrorNotification,
UndoRedoNotification,
},
@ -67,6 +70,7 @@ export default {
connecting: (state) => state.notification.connecting,
failedConnecting: (state) => state.notification.failedConnecting,
copying: (state) => state.notification.copying,
pasting: (state) => state.notification.pasting,
notifications: (state) => state.notification.items,
undoRedoState: (state) => state.notification.undoRedoState,
}),

View file

@ -0,0 +1,15 @@
<template>
<div class="alert alert--simple alert--with-shadow alert--has-icon">
<div class="alert__icon">
<div class="loading alert__icon-loading"></div>
</div>
<div class="alert__title">{{ $t('pastingNotification.title') }}</div>
<p class="alert__content">{{ $t('pastingNotification.content') }}</p>
</div>
</template>
<script>
export default {
name: 'PastingNotification',
}
</script>

View file

@ -187,6 +187,10 @@
"title": "Kopieren...",
"content": "Vorbereiten ihre Daten"
},
"pastingNotification": {
"title": "Einfügen...",
"content": "Vorbereiten ihre Daten"
},
"errorLayout": {
"notFound": "Die Seite, die Sie suchen, wurde nicht gefunden. Dies könnte daran liegen, dass die URL nicht korrekt ist oder dass Sie keine Berechtigung haben, diese Seite anzuzeigen.",
"error": "Beim Laden der Seite ist ein Fehler aufgetreten. Unsere Entwickler wurden über das Problem informiert. Bitte versuchen Sie, die Seite zu aktualisieren oder zum Dashboard zurückzukehren.",

View file

@ -192,6 +192,10 @@
"title": "Copying...",
"content": "Preparing your data"
},
"pastingNotification": {
"title": "Pasting...",
"content": "Preparing your data"
},
"undoRedoNotification": {
"undoingTitle": "Undoing...",
"undoingText": "Undoing your action",

View file

@ -163,6 +163,7 @@ export default function CoreModule(options) {
})
this.appendPlugin({ src: path.resolve(__dirname, 'plugins/auth.js') })
this.appendPlugin({ src: path.resolve(__dirname, 'plugins/featureFlags.js') })
this.appendPlugin({ src: path.resolve(__dirname, 'plugins/papa.js') })
this.extendRoutes((configRoutes) => {
// Remove all the routes created by nuxt.

View file

@ -0,0 +1,9 @@
import Papa from 'papaparse'
export default function () {
Papa.parsePromise = function (file, config = {}) {
return new Promise((resolve, reject) => {
Papa.parse(file, { complete: resolve, error: reject, ...config })
})
}
}

View file

@ -6,6 +6,7 @@ export const state = () => ({
failedConnecting: false,
authorizationError: false,
copying: false,
pasting: false,
// See UNDO_REDO_STATES for all possible values.
undoRedoState: UNDO_REDO_STATES.HIDDEN,
items: [],
@ -31,6 +32,9 @@ export const mutations = {
SET_COPYING(state, value) {
state.copying = value
},
SET_PASTING(state, value) {
state.pasting = value
},
SET_UNDO_REDO_STATE(state, value) {
state.undoRedoState = value
},
@ -88,6 +92,9 @@ export const actions = {
setCopying({ commit }, value) {
commit('SET_COPYING', value)
},
setPasting({ commit }, value) {
commit('SET_PASTING', value)
},
setUndoRedoState({ commit }, value) {
commit('SET_UNDO_REDO_STATE', value)
},

View file

@ -34,6 +34,7 @@
@cell-mouseup-left="multiSelectStop"
@add-row="addRow()"
@update="updateValue"
@paste="multiplePasteFromCell"
@edit="editValue"
@selected="selectedCell($event)"
@unselected="unselectedCell($event)"
@ -79,6 +80,7 @@
@row-context="showRowContext($event.event, $event.row)"
@add-row="addRow()"
@update="updateValue"
@paste="multiplePasteFromCell"
@edit="editValue"
@cell-mousedown-left="multiSelectStart"
@cell-mouseover="multiSelectHold"
@ -176,6 +178,7 @@
<script>
import { mapGetters } from 'vuex'
import Papa from 'papaparse'
import { notifyIf } from '@baserow/modules/core/utils/error'
import GridViewSection from '@baserow/modules/database/components/view/grid/GridViewSection'
@ -184,8 +187,8 @@ import GridViewRowDragging from '@baserow/modules/database/components/view/grid/
import RowEditModal from '@baserow/modules/database/components/row/RowEditModal'
import gridViewHelpers from '@baserow/modules/database/mixins/gridViewHelpers'
import { maxPossibleOrderValue } from '@baserow/modules/database/viewTypes'
import { isElement } from '@baserow/modules/core/utils/dom'
import viewHelpers from '@baserow/modules/database/mixins/viewHelpers'
import { isElement } from '@baserow/modules/core/utils/dom'
export default {
name: 'GridView',
@ -326,7 +329,8 @@ export default {
window.addEventListener('resize', this.$el.resizeEvent)
window.addEventListener('keydown', this.arrowEvent)
window.addEventListener('copy', this.exportMultiSelect)
window.addEventListener('click', this.cancelMultiSelect)
window.addEventListener('paste', this.pasteFromMultipleCellSelection)
window.addEventListener('click', this.cancelMultiSelectIfActive)
window.addEventListener('mouseup', this.multiSelectStop)
this.$refs.left.$el.addEventListener(
'scroll',
@ -341,14 +345,14 @@ export default {
window.removeEventListener('resize', this.$el.resizeEvent)
window.removeEventListener('keydown', this.arrowEvent)
window.removeEventListener('copy', this.exportMultiSelect)
window.removeEventListener('click', this.cancelMultiSelect)
window.removeEventListener('paste', this.pasteFromMultipleCellSelection)
window.removeEventListener('click', this.cancelMultiSelectIfActive)
window.removeEventListener('mouseup', this.multiSelectStop)
this.$bus.$off('field-deleted', this.fieldDeleted)
this.$store.dispatch(
this.storePrefix + 'view/grid/setMultiSelectActive',
false
this.storePrefix + 'view/grid/clearAndDisableMultiSelect'
)
this.$store.dispatch(this.storePrefix + 'view/grid/clearMultiSelect')
},
methods: {
/**
@ -749,10 +753,8 @@ export default {
}
this.$store.dispatch(
this.storePrefix + 'view/grid/setMultiSelectActive',
false
this.storePrefix + 'view/grid/clearAndDisableMultiSelect'
)
this.$store.dispatch(this.storePrefix + 'view/grid/clearMultiSelect')
this.$store.dispatch(this.storePrefix + 'view/grid/setSelectedCell', {
rowId: nextRowId,
@ -773,46 +775,44 @@ export default {
this.fieldsUpdated()
})
},
/*
Called when mouse is clicked and held on a GridViewCell component.
Starts multi-select by setting the head and tail index to the currently
selected cell.
*/
/**
* Called when mouse is clicked and held on a GridViewCell component.
* Starts multi-select by setting the head and tail index to the currently
* selected cell.
*/
multiSelectStart({ event, row, field }) {
this.$store.dispatch(this.storePrefix + 'view/grid/multiSelectStart', {
rowId: row.id,
fieldIndex: this.visibleFields.findIndex((f) => f.id === field.id) + 1,
})
},
/*
Called when mouse hovers over a GridViewCell component.
Updates the current multi-select grid by updating the tail index
with the last cell hovered over.
*/
/**
* Called when mouse hovers over a GridViewCell component.
* Updates the current multi-select grid by updating the tail index
* with the last cell hovered over.
*/
multiSelectHold({ event, row, field }) {
this.$store.dispatch(this.storePrefix + 'view/grid/multiSelectHold', {
rowId: row.id,
fieldIndex: this.visibleFields.findIndex((f) => f.id === field.id) + 1,
})
},
/*
Called when the mouse is unpressed over a GridViewCell component.
Stop multi-select.
*/
/**
* Called when the mouse is unpressed over a GridViewCell component.
* Stop multi-select.
*/
multiSelectStop({ event, row, field }) {
this.$store.dispatch(
this.storePrefix + 'view/grid/setMultiSelectHolding',
false
)
},
/*
Cancels multi-select if it's currently active.
This function checks if a mouse click event is triggered
outside of GridViewRows. This is done by ensuring that the
target element's class is either 'grid-view', 'grid-view__row',
or 'grid-view__rows'.
*/
cancelMultiSelect(event) {
/**
* Cancels multi-select if it's currently active.
* This function checks if a mouse click event is triggered
* outside of GridViewRows.
*/
cancelMultiSelectIfActive(event) {
if (
this.$store.getters[
this.storePrefix + 'view/grid/isMultiSelectActive'
@ -823,10 +823,8 @@ export default {
))
) {
this.$store.dispatch(
this.storePrefix + 'view/grid/setMultiSelectActive',
false
this.storePrefix + 'view/grid/clearAndDisableMultiSelect'
)
this.$store.dispatch(this.storePrefix + 'view/grid/clearMultiSelect')
}
},
arrowEvent(event) {
@ -841,14 +839,15 @@ export default {
]
) {
this.$store.dispatch(
this.storePrefix + 'view/grid/setMultiSelectActive',
false
this.storePrefix + 'view/grid/clearAndDisableMultiSelect'
)
this.$store.dispatch(this.storePrefix + 'view/grid/clearMultiSelect')
}
}
},
// Prepare and copy the multi-select cells into the clipboard, formatted as TSV
/**
* Prepare and copy the multi-select cells into the clipboard,
* formatted as TSV
*/
async exportMultiSelect(event) {
try {
this.$store.dispatch('notification/setCopying', true)
@ -856,8 +855,10 @@ export default {
this.storePrefix + 'view/grid/exportMultiSelect',
this.leftFields.concat(this.visibleFields)
)
// If the output is undefined, it means that there is no multiple selection.
if (output !== undefined) {
navigator.clipboard.writeText(output)
const tsv = Papa.unparse(output, { delimiter: '\t' })
navigator.clipboard.writeText(tsv)
}
} catch (error) {
notifyIf(error, 'view')
@ -865,6 +866,73 @@ export default {
this.$store.dispatch('notification/setCopying', false)
}
},
/**
* Called when the @paste event is triggered from the `GridViewSection` component.
* This happens when the individual cell doesn't understand the pasted data and
* needs to emit it up. This typically happens when multiple cell values are pasted.
*/
async multiplePasteFromCell({ data, field, row }) {
const rowIndex = this.$store.getters[
this.storePrefix + 'view/grid/getRowIndexById'
](row.id)
const fieldIndex =
this.visibleFields.findIndex((f) => f.id === field.id) + 1
await this.pasteData(data, rowIndex, fieldIndex)
},
/**
* Called when the user pastes data without having an individual cell selected. It
* only works when a multiple selection is active because then we know in which
* cells we can paste the data.
*/
async pasteFromMultipleCellSelection(event) {
if (!this.isMultiSelectActive) {
return
}
const parsed = await Papa.parsePromise(
event.clipboardData.getData('text'),
{ delimiter: '\t' }
)
const data = parsed.data
await this.pasteData(data)
},
/**
* Called when data must be pasted into the grid view. It basically forwards the
* request to a store action which handles the actual updating of rows. It also
* shows a loading animation while busy, so the user knows something is while the
* update is in progress.
*/
async pasteData(data, rowIndex, fieldIndex) {
// If the data is an empty array, we don't have to do anything because there is
// nothing to update. If the view is in read only mode, we can't paste so not
// doing anything.
if (data.length === 0 || data[0].length === 0 || this.readOnly) {
return
}
this.$store.dispatch('notification/setPasting', true)
try {
await this.$store.dispatch(
this.storePrefix + 'view/grid/updateDataIntoCells',
{
table: this.table,
view: this.view,
primary: this.primary,
fields: this.leftFields.concat(this.visibleFields),
getScrollTop: () => this.$refs.left.$refs.body.scrollTop,
data,
rowIndex,
fieldIndex,
}
)
} catch (error) {
notifyIf(error)
}
this.$store.dispatch('notification/setPasting', false)
return true
},
},
}
</script>

View file

@ -55,6 +55,7 @@
:store-prefix="props.storePrefix"
:read-only="props.readOnly"
@update="(...args) => $options.methods.update(listeners, props, ...args)"
@paste="(...args) => $options.methods.paste(listeners, props, ...args)"
@edit="(...args) => $options.methods.edit(listeners, props, ...args)"
@unselect="$options.methods.unselect(parent, props)"
@selected="$options.methods.selected(listeners, props, $event)"
@ -111,6 +112,15 @@ export default {
})
}
},
paste(listeners, props, event) {
if (listeners.paste) {
listeners.paste({
data: event,
row: props.row,
field: props.field,
})
}
},
/**
* If the grid field components emits an edit event then the user has changed the
* value without saving it yet. This is for example used to check in real time if

View file

@ -94,6 +94,7 @@
...getSelectedCellStyle(field),
}"
@update="$emit('update', $event)"
@paste="$emit('paste', $event)"
@edit="$emit('edit', $event)"
@select="$emit('select', $event)"
@unselect="$emit('unselect', $event)"
@ -241,9 +242,9 @@ export default {
this.$store.getters[this.storePrefix + 'view/grid/isMultiSelectActive']
) {
const rowIndex =
this.$store.getters[
this.storePrefix + 'view/grid/getMultiSelectRowIndexById'
](rowId)
this.$store.getters[this.storePrefix + 'view/grid/getRowIndexById'](
rowId
)
const allFieldIds = this.allFields.map((field) => field.id)
let fieldIndex = allFieldIds.findIndex((id) => field.id === id)

View file

@ -325,7 +325,7 @@ export class FieldType extends Registerable {
* By default the text value if the clipboard data is used.
*/
prepareValueForPaste(field, clipboardData) {
return clipboardData.getData('text')
return clipboardData
}
/**
@ -442,14 +442,7 @@ export class FieldType extends Registerable {
* is for example used by the last modified field type to update the last modified
* value in real time when a row has changed.
*/
onRowChange(
row,
updatedField,
updatedFieldValue,
updatedFieldOldValue,
currentField,
currentFieldValue
) {
onRowChange(row, currentField, currentFieldValue) {
return currentFieldValue
}
@ -714,7 +707,7 @@ export class LinkRowFieldType extends FieldType {
let values
try {
values = JSON.parse(clipboardData.getData('text'))
values = JSON.parse(clipboardData)
} catch (SyntaxError) {
return []
}
@ -880,7 +873,7 @@ export class NumberFieldType extends FieldType {
* to be formatted.
*/
prepareValueForPaste(field, clipboardData) {
const value = clipboardData.getData('text')
const value = clipboardData
if (
isNaN(parseFloat(value)) ||
!isFinite(value) ||
@ -1012,7 +1005,7 @@ export class RatingFieldType extends FieldType {
* to be formatted.
*/
prepareValueForPaste(field, clipboardData) {
const pastedValue = clipboardData.getData('text')
const pastedValue = clipboardData
const value = parseInt(pastedValue, 10)
if (isNaN(value) || !isFinite(value)) {
@ -1101,7 +1094,7 @@ export class BooleanFieldType extends FieldType {
* value is true.
*/
prepareValueForPaste(field, clipboardData) {
const value = clipboardData.getData('text').toLowerCase().trim()
const value = clipboardData.toLowerCase().trim()
return trueString.includes(value)
}
@ -1193,7 +1186,7 @@ class BaseDateFieldType extends FieldType {
* correct format for the field. If it can't be parsed null is returned.
*/
prepareValueForPaste(field, clipboardData) {
const value = clipboardData.getData('text').toUpperCase()
const value = clipboardData.toUpperCase()
// Formats for ISO dates
let formats = [
@ -1352,14 +1345,7 @@ export class LastModifiedFieldType extends CreatedOnLastModifiedBaseFieldType {
return moment().utc().format()
}
onRowChange(
row,
updatedField,
updatedFieldValue,
updatedFieldOldValue,
currentField,
currentFieldValue
) {
onRowChange(row, currentField, currentFieldValue) {
return this._onRowChangeOrMove()
}
@ -1421,7 +1407,7 @@ export class URLFieldType extends FieldType {
}
prepareValueForPaste(field, clipboardData) {
const value = clipboardData.getData('text')
const value = clipboardData
return isValidURL(value) ? value : ''
}
@ -1498,7 +1484,7 @@ export class EmailFieldType extends FieldType {
}
prepareValueForPaste(field, clipboardData) {
const value = clipboardData.getData('text')
const value = clipboardData
return isValidEmail(value) ? value : ''
}
@ -1597,7 +1583,7 @@ export class FileFieldType extends FieldType {
let value
try {
value = JSON.parse(clipboardData.getData('text'))
value = JSON.parse(clipboardData)
} catch (SyntaxError) {
return []
}
@ -1736,7 +1722,7 @@ export class SingleSelectFieldType extends FieldType {
if (value === undefined || value === null) {
return ''
}
return value.id
return value.value
}
_findOptionWithMatchingId(field, rawTextValue) {
@ -1755,7 +1741,7 @@ export class SingleSelectFieldType extends FieldType {
}
prepareValueForPaste(field, clipboardData) {
const rawTextValue = clipboardData.getData('text')
const rawTextValue = clipboardData
return (
this._findOptionWithMatchingId(field, rawTextValue) ||
@ -1895,7 +1881,7 @@ export class MultipleSelectFieldType extends FieldType {
prepareValueForPaste(field, clipboardData) {
let values
try {
values = JSON.parse(clipboardData.getData('text'))
values = JSON.parse(clipboardData)
} catch (SyntaxError) {
return []
}
@ -2006,7 +1992,7 @@ export class PhoneNumberFieldType extends FieldType {
}
prepareValueForPaste(field, clipboardData) {
const value = clipboardData.getData('text')
const value = clipboardData
return isSimplePhoneNumber(value) ? value : ''
}

View file

@ -1,6 +1,7 @@
import { isElement } from '@baserow/modules/core/utils/dom'
import { copyToClipboard } from '@baserow/modules/database/utils/clipboard'
import baseField from '@baserow/modules/database/mixins/baseField'
import Papa from 'papaparse'
/**
* A mixin that can be used by a field grid component. It introduces the props that
@ -140,7 +141,8 @@ export default {
const value = this.$registry
.get('field', this.field.type)
.prepareValueForCopy(this.field, rawValue)
copyToClipboard(value)
const tsv = Papa.unparse([[value]], { delimiter: '\t' })
copyToClipboard(tsv)
}
// Removes the value if the backspace/delete key is pressed.
@ -162,23 +164,40 @@ export default {
document.body.addEventListener('keydown', this.$el.keyDownEvent)
// Updates the value of the field when a user pastes something in the field.
this.$el.pasteEvent = (event) => {
this.$el.pasteEvent = async (event) => {
if (!this.canPaste(event)) {
return
}
const value = this.$registry
.get('field', this.field.type)
.prepareValueForPaste(this.field, event.clipboardData)
const oldValue = this.value
if (
value !== undefined &&
value !== oldValue &&
!this.readOnly &&
!this.field._.type.isReadOnly
) {
this.$emit('update', value, oldValue)
}
try {
// Multiple values in TSV format can be provided, so we need to properly
// parse it using Papa.
const parsed = await Papa.parsePromise(
event.clipboardData.getData('text'),
{ delimiter: '\t' }
)
const data = parsed.data
// A grid field cell can only handle one single value. We try to extract
// that from the clipboard and update the cell, otherwise we emit the
// paste event up.
if (data.length === 1 && data[0].length === 1) {
const value = this.$registry
.get('field', this.field.type)
.prepareValueForPaste(this.field, data[0][0])
const oldValue = this.value
if (
value !== undefined &&
value !== oldValue &&
!this.readOnly &&
!this.field._.type.isReadOnly
) {
this.$emit('update', value, oldValue)
}
} else {
this.$emit('paste', data)
}
} catch (e) {}
}
document.addEventListener('paste', this.$el.pasteEvent)

View file

@ -72,6 +72,9 @@ export default (client) => {
update(tableId, rowId, values) {
return client.patch(`/database/rows/table/${tableId}/${rowId}/`, values)
},
batchUpdate(tableId, items) {
return client.patch(`/database/rows/table/${tableId}/batch/`, { items })
},
/**
* Moves the row to the position before the row related to the beforeRowId
* parameters. If the before id is not provided then the row will be moved

View file

@ -672,9 +672,6 @@ export default ({ service, customPopulateRow }) => {
const currentFieldValue = row[fieldToCallName]
const optimisticFieldValue = fieldType.onRowChange(
row,
field,
value,
oldValue,
fieldToCall,
currentFieldValue
)
@ -708,6 +705,7 @@ export default ({ service, customPopulateRow }) => {
row,
values: oldValues,
})
dispatch('fetchAllFieldAggregationData', { view })
throw error
}
},

View file

@ -83,13 +83,13 @@ export const state = () => ({
multiSelectActive: false,
// Indicates if the user is clicking and holding the mouse over a cell
multiSelectHolding: false,
/*
The indexes for head and tail cells in a multi-select grid.
Multi-Select works by tracking four different indexes, these are:
- The field and row index for the first cell selected, known as the head.
- The field and row index for the last cell selected, known as the tail.
All the cells between the head and tail cells are later also calculated as selected.
*/
/**
* The indexes for head and tail cells in a multi-select grid.
* Multi-Select works by tracking four different indexes, these are:
* - The field and row index for the first cell selected, known as the head.
* - The field and row index for the last cell selected, known as the tail.
* All the cells between the head and tail cells are later also calculated as selected.
*/
multiSelectHeadRowIndex: -1,
multiSelectHeadFieldIndex: -1,
multiSelectTailRowIndex: -1,
@ -1143,13 +1143,14 @@ export const actions = {
setMultiSelectActive({ commit }, value) {
commit('SET_MULTISELECT_ACTIVE', value)
},
clearMultiSelect({ commit }) {
clearAndDisableMultiSelect({ commit }) {
commit('CLEAR_MULTISELECT')
commit('SET_MULTISELECT_ACTIVE', false)
},
multiSelectStart({ getters, commit }, { rowId, fieldIndex }) {
commit('CLEAR_MULTISELECT')
const rowIndex = getters.getMultiSelectRowIndexById(rowId)
const rowIndex = getters.getRowIndexById(rowId)
// Set the head and tail index to highlight the first cell
commit('UPDATE_MULTISELECT', { position: 'head', rowIndex, fieldIndex })
commit('UPDATE_MULTISELECT', { position: 'tail', rowIndex, fieldIndex })
@ -1166,75 +1167,84 @@ export const actions = {
commit('UPDATE_MULTISELECT', {
position: 'tail',
rowIndex: getters.getMultiSelectRowIndexById(rowId),
rowIndex: getters.getRowIndexById(rowId),
fieldIndex,
})
commit('SET_MULTISELECT_ACTIVE', true)
}
},
/**
* Prepares a two dimensional array containing prepared values for copy. It only
* contains the cell values selected by the multiple select. If one or more rows
* are not in the buffer, they are fetched from the backend.
*/
async exportMultiSelect({ dispatch, getters, commit }, fields) {
if (getters.isMultiSelectActive) {
const output = []
const [minFieldIndex, maxFieldIndex] =
getters.getMultiSelectFieldIndexSorted
let rows = []
fields = fields.slice(minFieldIndex, maxFieldIndex + 1)
if (getters.areMultiSelectRowsWithinBuffer) {
rows = getters.getSelectedRows
} else {
// Fetch rows from backend
rows = await dispatch('getMultiSelectedRows', fields)
}
// Loop over selected rows
for (const row of rows) {
const line = []
// Loop over selected fields
for (const field of fields) {
const rawValue = row['field_' + field.id]
// Format the value for copying using the field's prepareValueForCopy()
const value = this.$registry
.get('field', field.type)
.toHumanReadableString(field, rawValue)
line.push(JSON.stringify(value))
}
output.push(line.join('\t'))
}
return output.join('\n')
if (!getters.isMultiSelectActive) {
return
}
},
/*
This function is called if a user attempts to access rows that are
no longer in the row buffer and need to be fetched from the backend.
A user can select some or all fields in a row, and only those fields
will be returned.
*/
async getMultiSelectedRows({ getters, rootGetters }, fields) {
const [minRow, maxRow] = getters.getMultiSelectRowIndexSorted
const gridId = getters.getLastGridId
const [minFieldIndex, maxFieldIndex] =
getters.getMultiSelectFieldIndexSorted
return await GridService(this.$client)
.fetchRows({
gridId,
offset: minRow,
limit: maxRow - minRow + 1,
search: getters.getServerSearchTerm,
publicUrl: getters.isPublic,
publicAuthToken: getters.getPublicAuthToken,
orderBy: getOrderBy(getters, rootGetters),
filters: getFilters(getters, rootGetters),
includeFields: fields.map((field) => `field_${field.id}`),
})
.catch((error) => {
throw error
})
.then(({ data }) => {
return data.results
let rows = []
fields = fields.slice(minFieldIndex, maxFieldIndex + 1)
if (getters.areMultiSelectRowsWithinBuffer) {
rows = getters.getSelectedRows
} else {
// Fetch rows from backend
const [minRowIndex, maxRowIndex] = getters.getMultiSelectRowIndexSorted
const limit = maxRowIndex - minRowIndex + 1
rows = await dispatch('fetchRowsByIndex', {
startIndex: minRowIndex,
limit,
fields,
})
}
const data = []
for (const row of rows) {
const line = []
for (const field of fields) {
const rawValue = row['field_' + field.id]
const value = this.$registry
.get('field', field.type)
.prepareValueForCopy(field, rawValue)
line.push(value)
}
data.push(line)
}
return data
},
/**
* This function is called if a user attempts to access rows that are
* no longer in the row buffer and need to be fetched from the backend.
* A user can select some or all fields in a row, and only those fields
* will be returned.
*/
async fetchRowsByIndex(
{ getters, rootGetters },
{ startIndex, limit, fields }
) {
if (fields !== undefined) {
fields = fields.map((field) => `field_${field.id}`)
}
const gridId = getters.getLastGridId
const { data } = await GridService(this.$client).fetchRows({
gridId,
offset: startIndex,
limit,
search: getters.getServerSearchTerm,
publicUrl: getters.isPublic,
publicAuthToken: getters.getPublicAuthToken,
orderBy: getOrderBy(getters, rootGetters),
filters: getFilters(getters, rootGetters),
includeFields: fields,
})
return data.results
},
setRowHover({ commit }, { row, value }) {
commit('SET_ROW_HOVER', { row, value })
@ -1358,10 +1368,6 @@ export const actions = {
dispatch('updateMatchFilters', { view, row, fields, primary })
dispatch('updateSearchMatchesForRow', { row, fields, primary })
dispatch('fetchAllFieldAggregationData', {
view,
})
// If the row does not match the filters or the search then we don't have to add
// it at all.
if (!row._.matchFilters || !row._.matchSearch) {
@ -1479,6 +1485,7 @@ export const actions = {
fields,
primary,
})
dispatch('fetchAllFieldAggregationData', { view: grid })
} catch (error) {
dispatch('updatedExistingRow', {
view: grid,
@ -1524,9 +1531,6 @@ export const actions = {
const currentFieldValue = row[fieldID]
const optimisticFieldValue = fieldType.onRowChange(
row,
field,
value,
oldValue,
fieldToCall,
currentFieldValue
)
@ -1568,6 +1572,140 @@ export const actions = {
throw error
}
},
/**
* This action is used by the grid view to change multiple cells when pasting
* multiple values. It figures out which cells need to be changed, makes a request
* to the backend and updates the affected rows in the store.
*/
async updateDataIntoCells(
{ getters, commit, dispatch },
{ table, view, primary, fields, getScrollTop, data, rowIndex, fieldIndex }
) {
// If the origin origin row and field index are not provided, we need to use the
// head indexes of the multiple select.
const rowHeadIndex = rowIndex || getters.getMultiSelectHeadRowIndex
const fieldHeadIndex = fieldIndex || getters.getMultiSelectHeadFieldIndex
// Based on the data, we can figure out in which cells we must paste. Here we find
// the maximum tail indexes.
const rowTailIndex =
Math.min(getters.getCount, rowHeadIndex + data.length) - 1
const fieldTailIndex =
Math.min(fields.length, fieldHeadIndex + data[0].length) - 1
// Expand the selection of the multiple select to the cells that we're going to
// paste in, so the user can see which values have been updated. This is because
// it could be that there are more or less values in the clipboard compared to
// what was originally selected.
commit('UPDATE_MULTISELECT', {
position: 'head',
rowIndex: rowHeadIndex,
fieldIndex: fieldHeadIndex,
})
commit('UPDATE_MULTISELECT', {
position: 'tail',
rowIndex: rowTailIndex,
fieldIndex: fieldTailIndex,
})
commit('SET_MULTISELECT_ACTIVE', true)
// Unselect a single selected cell because we've just updated the multiple
// selected and we don't want that to conflict.
commit('SET_SELECTED_CELL', { rowId: -1, fieldId: -1 })
// Figure out which rows are already in the buffered and temporarily store them
// in an array.
const fieldsInOrder = fields.slice(fieldHeadIndex, fieldTailIndex + 1)
let rowsInOrder = getters.getAllRows.slice(
rowHeadIndex - getters.getBufferStartIndex,
rowTailIndex + 1 - getters.getBufferStartIndex
)
// Check if there are fields that can be updated. If there aren't any fields,
// maybe because the provided index is outside of the available fields or
// because there are only read only fields, we don't want to do anything.
const writeFields = fieldsInOrder.filter(
(field) => !field._.type.isReadOnly
)
if (writeFields.length === 0) {
return
}
// Calculate if there are rows outside of the buffer that need to be fetched and
// prepended or appended to the `rowsInOrder`
const startIndex = rowHeadIndex + rowsInOrder.length
const limit = rowTailIndex - rowHeadIndex - rowsInOrder.length + 1
if (limit > 0) {
const rowsNotInBuffer = await dispatch('fetchRowsByIndex', {
startIndex,
limit,
})
// Depends on whether the missing rows are before or after the buffer.
rowsInOrder =
startIndex < getters.getBufferStartIndex
? [...rowsNotInBuffer, ...rowsInOrder]
: [...rowsInOrder, ...rowsNotInBuffer]
}
// Create a copy of the existing (old) rows, which are needed to create the
// comparison when checking if the rows still matches the filters and position.
const oldRowsInOrder = clone(rowsInOrder)
// Prepare the values that must be send to the server.
const valuesForUpdate = []
// Prepare the values for update and update the row objects.
rowsInOrder.forEach((row, rowIndex) => {
valuesForUpdate[rowIndex] = { id: row.id }
fieldsInOrder.forEach((field, fieldIndex) => {
// We can't pre-filter because we need the correct filter index.
if (field._.type.isReadOnly) {
return
}
const fieldId = `field_${field.id}`
const value = data[rowIndex][fieldIndex]
const fieldType = this.$registry.get('field', field._.type.type)
const preparedValue = fieldType.prepareValueForPaste(field, value)
const newValue = fieldType.prepareValueForUpdate(field, preparedValue)
valuesForUpdate[rowIndex][fieldId] = newValue
})
})
// We don't have to update the rows in the buffer before the request is being made
// because we're showing a loading animation to the user indicating that the
// rows are being updated.
const { data: responseData } = await RowService(this.$client).batchUpdate(
table.id,
valuesForUpdate
)
const updatedRows = responseData.items
// Loop over the old rows, find the matching updated row and update them in the
// buffer accordingly.
for (const row of oldRowsInOrder) {
// The values are the updated row returned by the response.
const values = updatedRows.find((updatedRow) => updatedRow.id === row.id)
// Calling the updatedExistingRow will automatically remove the row from the
// view if it doesn't matter the filters anymore and it will also be moved to
// the right position if changed.
await dispatch('updatedExistingRow', {
view,
fields,
primary,
row,
values,
})
}
// Must be called because rows could have been removed or moved to a different
// position and we might need to fetch missing rows.
await dispatch('fetchByScrollTopDelayed', {
scrollTop: getScrollTop(),
fields,
primary,
})
dispatch('fetchAllFieldAggregationData', { view })
},
/**
* Called after an existing row has been updated, which could be by the user or
* via another channel. It will make sure that the row has the correct position or
@ -1588,10 +1726,6 @@ export const actions = {
dispatch('updateMatchFilters', { view, row: newRow, fields, primary })
dispatch('updateSearchMatchesForRow', { row: newRow, fields, primary })
dispatch('fetchAllFieldAggregationData', {
view,
})
const oldRowExists = oldRow._.matchFilters && oldRow._.matchSearch
const newRowExists = newRow._.matchFilters && newRow._.matchSearch
@ -1730,6 +1864,7 @@ export const actions = {
fields,
primary,
})
dispatch('fetchAllFieldAggregationData', { view })
} catch (error) {
commit('SET_ROW_LOADING', { row, value: false })
throw error
@ -1750,10 +1885,6 @@ export const actions = {
dispatch('updateMatchFilters', { view, row, fields, primary })
dispatch('updateSearchMatchesForRow', { row, fields, primary })
dispatch('fetchAllFieldAggregationData', {
view,
})
// If the row does not match the filters or the search then did not exist in the
// view, so we don't have to do anything.
if (!row._.matchFilters || !row._.matchSearch) {
@ -2061,12 +2192,18 @@ export const getters = {
),
]
},
getMultiSelectHeadFieldIndex(state) {
return state.multiSelectHeadFieldIndex
},
getMultiSelectHeadRowIndex(state) {
return state.multiSelectHeadRowIndex
},
// Get the index of a row given it's row id.
// This will calculate the row index from the current buffer position and offset.
getMultiSelectRowIndexById: (state) => (rowId) => {
getRowIndexById: (state, getters) => (rowId) => {
const bufferIndex = state.rows.findIndex((r) => r.id === rowId)
if (bufferIndex !== -1) {
return state.bufferStartIndex + bufferIndex
return getters.getBufferStartIndex + bufferIndex
}
return -1
},
@ -2075,8 +2212,8 @@ export const getters = {
const [minRow, maxRow] = getters.getMultiSelectRowIndexSorted
return (
minRow >= state.bufferStartIndex &&
maxRow <= state.bufferStartIndex + state.bufferRequestSize
minRow >= getters.getBufferStartIndex &&
maxRow <= getters.getBufferEndIndex
)
},
// Return all rows within a multi-select grid if they are within the current row buffer

View file

@ -500,6 +500,9 @@ export class GridViewType extends ViewType {
fields,
primary,
})
store.dispatch(storePrefix + 'view/grid/fetchAllFieldAggregationData', {
view: store.getters['view/getSelected'],
})
}
}
@ -527,6 +530,9 @@ export class GridViewType extends ViewType {
fields,
primary,
})
store.dispatch(storePrefix + 'view/grid/fetchAllFieldAggregationData', {
view: store.getters['view/getSelected'],
})
}
}
@ -543,6 +549,9 @@ export class GridViewType extends ViewType {
fields,
primary,
})
store.dispatch(storePrefix + 'view/grid/fetchAllFieldAggregationData', {
view: store.getters['view/getSelected'],
})
}
}

View file

@ -23,6 +23,8 @@ exports[`Public View Page Tests Can see a publicly shared grid view 1`] = `
<!---->
<!---->
</div>
</div>

View file

@ -325,15 +325,9 @@ describe('FieldType tests', () => {
test.each(datePrepareValueForPaste)(
'Verify that prepareValueForPaste for DateFieldType returns the expected output',
(value) => {
const clipboardData = {
getData() {
return value.fieldValue
},
}
const result = new DateFieldType().prepareValueForPaste(
value.field,
clipboardData
value.fieldValue
)
expect(result).toBe(value.expectedValue)
}