mirror of
https://gitlab.com/bramw/baserow.git
synced 2024-11-25 00:46:46 +00:00
1608 lines
54 KiB
Vue
1608 lines
54 KiB
Vue
<template>
|
|
<div
|
|
v-scroll="scroll"
|
|
class="grid-view"
|
|
:class="[
|
|
{
|
|
'grid-view--disable-selection': isMultiSelectActive,
|
|
},
|
|
'grid-view--row-height-' + view.row_height_size,
|
|
]"
|
|
>
|
|
<Scrollbars
|
|
ref="scrollbars"
|
|
horizontal="getHorizontalScrollbarElement"
|
|
vertical="getVerticalScrollbarElement"
|
|
:style="{ left: leftWidth + 'px' }"
|
|
@vertical="verticalScroll"
|
|
@horizontal="horizontalScroll"
|
|
></Scrollbars>
|
|
<GridViewSection
|
|
ref="left"
|
|
class="grid-view__left"
|
|
:visible-fields="leftFields"
|
|
:all-fields-in-table="fields"
|
|
:decorations-by-place="decorationsByPlace"
|
|
:database="database"
|
|
:table="table"
|
|
:view="view"
|
|
:include-field-width-handles="false"
|
|
:include-row-details="!viewHasGroupBys"
|
|
:include-grid-view-identifier-dropdown="!viewHasGroupBys"
|
|
:include-group-by="true"
|
|
:read-only="
|
|
readOnly ||
|
|
!$hasPermission(
|
|
'database.table.update_row',
|
|
table,
|
|
database.workspace.id
|
|
)
|
|
"
|
|
:store-prefix="storePrefix"
|
|
:style="{ width: leftWidth + 'px' }"
|
|
@refresh="$emit('refresh', $event)"
|
|
@field-created="fieldCreated"
|
|
@row-hover="setRowHover($event.row, $event.value)"
|
|
@row-context="showRowContext($event.event, $event.row)"
|
|
@row-dragging="rowDragStart"
|
|
@cell-mousedown-left="multiSelectStart"
|
|
@cell-mouseover="multiSelectHold"
|
|
@cell-mouseup-left="multiSelectStop"
|
|
@cell-shift-click="multiSelectShiftClick"
|
|
@add-row="addRow()"
|
|
@add-rows="$refs.rowsAddContext.toggleNextToMouse($event)"
|
|
@add-row-after="addRowAfter($event)"
|
|
@update="updateValue"
|
|
@paste="multiplePasteFromCell"
|
|
@edit="editValue"
|
|
@selected="selectedCell"
|
|
@unselected="unselectedCell"
|
|
@select-next="selectNextCell"
|
|
@edit-modal="openRowEditModal($event)"
|
|
@refresh-row="refreshRow"
|
|
@scroll="scroll($event.pixelY, 0)"
|
|
@cell-selected="cellSelected"
|
|
></GridViewSection>
|
|
<GridViewRowsAddContext ref="rowsAddContext" @add-rows="addRows" />
|
|
<div
|
|
ref="divider"
|
|
class="grid-view__divider"
|
|
:style="{ left: leftWidth + 'px' }"
|
|
></div>
|
|
<HorizontalResize
|
|
v-if="primaryFieldIsSticky"
|
|
class="grid-view__divider-width"
|
|
:style="{ left: leftWidth + 'px' }"
|
|
:width="leftFieldsWidth"
|
|
:min="100"
|
|
@move="moveFieldWidth(leftFields[0], $event)"
|
|
@update="
|
|
updateFieldWidth(leftFields[0], view, database, readOnly, $event)
|
|
"
|
|
></HorizontalResize>
|
|
<HorizontalResize
|
|
v-else-if="viewHasGroupBys && leftFields.length === 0"
|
|
class="grid-view__divider-width"
|
|
:style="{ left: leftWidth + 'px' }"
|
|
:width="activeGroupBys[activeGroupBys.length - 1].width"
|
|
:min="100"
|
|
@move="
|
|
moveGroupWidth(activeGroupBys[activeGroupBys.length - 1], view, $event)
|
|
"
|
|
@update="
|
|
updateGroupWidth(
|
|
activeGroupBys[activeGroupBys.length - 1],
|
|
view,
|
|
database,
|
|
readOnly,
|
|
$event
|
|
)
|
|
"
|
|
></HorizontalResize>
|
|
<GridViewSection
|
|
ref="right"
|
|
class="grid-view__right"
|
|
:visible-fields="rightVisibleFields"
|
|
:all-fields-in-table="fields"
|
|
:decorations-by-place="decorationsByPlace"
|
|
:database="database"
|
|
:primary-field-is-sticky="primaryFieldIsSticky"
|
|
:table="table"
|
|
:view="view"
|
|
:include-row-details="viewHasGroupBys"
|
|
:include-grid-view-identifier-dropdown="viewHasGroupBys"
|
|
:include-add-field="true"
|
|
:can-order-fields="true"
|
|
:read-only="
|
|
readOnly ||
|
|
!$hasPermission(
|
|
'database.table.update_row',
|
|
table,
|
|
database.workspace.id
|
|
)
|
|
"
|
|
:store-prefix="storePrefix"
|
|
:style="{ left: leftWidth + 'px' }"
|
|
@refresh="$emit('refresh', $event)"
|
|
@field-created="fieldCreated"
|
|
@row-hover="setRowHover($event.row, $event.value)"
|
|
@row-context="showRowContext($event.event, $event.row)"
|
|
@add-row="addRow()"
|
|
@add-rows="$refs.rowsAddContext.toggleNextToMouse($event)"
|
|
@add-row-after="addRowAfter($event)"
|
|
@update="updateValue"
|
|
@paste="multiplePasteFromCell"
|
|
@edit="editValue"
|
|
@row-dragging="rowDragStart"
|
|
@cell-mousedown-left="multiSelectStart"
|
|
@cell-mouseover="multiSelectHold"
|
|
@cell-mouseup-left="multiSelectStop"
|
|
@cell-shift-click="multiSelectShiftClick"
|
|
@selected="selectedCell"
|
|
@unselected="unselectedCell"
|
|
@select-next="selectNextCell"
|
|
@edit-modal="openRowEditModal($event)"
|
|
@refresh-row="refreshRow"
|
|
@scroll="scroll($event.pixelY, $event.pixelX)"
|
|
@cell-selected="cellSelected"
|
|
></GridViewSection>
|
|
<GridViewRowDragging
|
|
ref="rowDragging"
|
|
:table="table"
|
|
:view="view"
|
|
:all-visible-fields="allVisibleFields"
|
|
:all-fields-in-table="fields"
|
|
:store-prefix="storePrefix"
|
|
:offset="activeGroupByWidth"
|
|
vertical="getVerticalScrollbarElement"
|
|
@scroll="scroll($event.pixelY, $event.pixelX)"
|
|
></GridViewRowDragging>
|
|
<Context ref="rowContext" overflow-scroll max-height-if-outside-viewport>
|
|
<ul v-show="isMultiSelectActive" class="context__menu">
|
|
<component
|
|
:is="contextItemComponent"
|
|
v-for="(contextItemComponent, index) in getMultiSelectContextItems()"
|
|
:key="index"
|
|
:field="getSelectedField()"
|
|
:get-rows="getSelectedRowsFunction"
|
|
:store-prefix="storePrefix"
|
|
:database="database"
|
|
@click=";[$refs.rowContext.hide()]"
|
|
></component>
|
|
<li class="context__menu-item">
|
|
<a
|
|
class="context__menu-item-link"
|
|
@click=";[copySelection($event, false), $refs.rowContext.hide()]"
|
|
>
|
|
<i class="context__menu-item-icon iconoir-copy"></i>
|
|
{{ $t('gridView.copyCells') }}
|
|
</a>
|
|
</li>
|
|
<li class="context__menu-item">
|
|
<a
|
|
class="context__menu-item-link"
|
|
@click=";[copySelection($event, true), $refs.rowContext.hide()]"
|
|
>
|
|
<i class="context__menu-item-icon iconoir-copy"></i>
|
|
{{ $t('gridView.copyCellsWithHeader') }}
|
|
</a>
|
|
</li>
|
|
<li
|
|
v-if="
|
|
!readOnly &&
|
|
!table.data_sync &&
|
|
$hasPermission(
|
|
'database.table.delete_row',
|
|
table,
|
|
database.workspace.id
|
|
)
|
|
"
|
|
class="context__menu-item"
|
|
>
|
|
<a
|
|
class="context__menu-item-link"
|
|
:class="{ 'context__menu-item-link--loading': deletingRow }"
|
|
@click.stop="deleteRowsFromMultipleCellSelection()"
|
|
>
|
|
<i class="context__menu-item-icon iconoir-bin"></i>
|
|
{{ $t('gridView.deleteRows') }}
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
<ul v-show="!isMultiSelectActive" class="context__menu">
|
|
<li>
|
|
<a
|
|
class="context__menu-item-link"
|
|
@click=";[selectRow($event, selectedRow), $refs.rowContext.hide()]"
|
|
>
|
|
<i class="context__menu-item-icon iconoir-check-circle"></i>
|
|
{{ $t('gridView.selectRow') }}
|
|
</a>
|
|
</li>
|
|
<li
|
|
v-if="
|
|
!readOnly &&
|
|
!table.data_sync &&
|
|
$hasPermission(
|
|
'database.table.create_row',
|
|
table,
|
|
database.workspace.id
|
|
)
|
|
"
|
|
class="context__menu-item"
|
|
>
|
|
<a
|
|
class="context__menu-item-link"
|
|
@click="addRowAboveSelectedRow($event, selectedRow)"
|
|
>
|
|
<i class="context__menu-item-icon iconoir-arrow-up"></i>
|
|
{{ $t('gridView.insertRowAbove') }}
|
|
</a>
|
|
</li>
|
|
<li
|
|
v-if="
|
|
!readOnly &&
|
|
!table.data_sync &&
|
|
$hasPermission(
|
|
'database.table.create_row',
|
|
table,
|
|
database.workspace.id
|
|
)
|
|
"
|
|
class="context__menu-item"
|
|
>
|
|
<a
|
|
class="context__menu-item-link"
|
|
@click="addRowBelowSelectedRow($event, selectedRow)"
|
|
>
|
|
<i class="context__menu-item-icon iconoir-arrow-down"></i>
|
|
{{ $t('gridView.insertRowBelow') }}
|
|
</a>
|
|
</li>
|
|
<li
|
|
v-if="
|
|
!readOnly &&
|
|
!table.data_sync &&
|
|
$hasPermission(
|
|
'database.table.create_row',
|
|
table,
|
|
database.workspace.id
|
|
)
|
|
"
|
|
class="context__menu-item"
|
|
>
|
|
<a
|
|
class="context__menu-item-link"
|
|
@click="duplicateSelectedRow($event, selectedRow)"
|
|
>
|
|
<i class="context__menu-item-icon iconoir-copy"></i>
|
|
{{ $t('gridView.duplicateRow') }}
|
|
</a>
|
|
</li>
|
|
<li v-if="!readOnly" class="context__menu-item">
|
|
<a
|
|
class="context__menu-item-link"
|
|
@click="copyLinkToSelectedRow($event, selectedRow)"
|
|
>
|
|
<i class="context__menu-item-icon iconoir-link"></i>
|
|
{{ $t('gridView.copyRowURL') }}
|
|
</a>
|
|
</li>
|
|
<li
|
|
v-if="selectedRow !== null && !selectedRow._.loading"
|
|
class="context__menu-item"
|
|
>
|
|
<a
|
|
class="context__menu-item-link"
|
|
@click=";[openRowEditModal(selectedRow), $refs.rowContext.hide()]"
|
|
>
|
|
<i class="context__menu-item-icon iconoir-expand"></i>
|
|
{{ $t('gridView.enlargeRow') }}
|
|
</a>
|
|
</li>
|
|
<li
|
|
v-if="
|
|
!readOnly &&
|
|
!table.data_sync &&
|
|
$hasPermission(
|
|
'database.table.delete_row',
|
|
table,
|
|
database.workspace.id
|
|
)
|
|
"
|
|
class="context__menu-item"
|
|
>
|
|
<a class="context__menu-item-link" @click="deleteRow(selectedRow)">
|
|
<i class="context__menu-item-icon iconoir-bin"></i>
|
|
{{ $t('gridView.deleteRow') }}
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
</Context>
|
|
<RowEditModal
|
|
ref="rowEditModal"
|
|
:database="database"
|
|
:table="table"
|
|
:view="view"
|
|
:all-fields-in-table="fields"
|
|
:visible-fields="allVisibleFields"
|
|
:hidden-fields="hiddenFields"
|
|
:rows="allRows"
|
|
:sortable="true"
|
|
:can-modify-fields="true"
|
|
:read-only="
|
|
readOnly ||
|
|
!$hasPermission(
|
|
'database.table.update_row',
|
|
table,
|
|
database.workspace.id
|
|
)
|
|
"
|
|
:enable-navigation="
|
|
!readOnly &&
|
|
$hasPermission(
|
|
'database.table.update_row',
|
|
table,
|
|
database.workspace.id
|
|
)
|
|
"
|
|
:show-hidden-fields="showHiddenFieldsInRowModal"
|
|
@toggle-hidden-fields-visibility="
|
|
showHiddenFieldsInRowModal = !showHiddenFieldsInRowModal
|
|
"
|
|
@update="updateValue"
|
|
@toggle-field-visibility="toggleFieldVisibility"
|
|
@order-fields="orderFields"
|
|
@hidden="rowEditModalHidden"
|
|
@field-updated="$emit('refresh', $event)"
|
|
@field-deleted="$emit('refresh')"
|
|
@field-created="fieldCreated"
|
|
@field-created-callback-done="afterFieldCreatedUpdateFieldOptions"
|
|
@navigate-previous="$emit('navigate-previous', $event, activeSearchTerm)"
|
|
@navigate-next="$emit('navigate-next', $event, activeSearchTerm)"
|
|
@refresh-row="refreshRow"
|
|
></RowEditModal>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import { mapGetters } from 'vuex'
|
|
import ResizeObserver from 'resize-observer-polyfill'
|
|
|
|
import { notifyIf } from '@baserow/modules/core/utils/error'
|
|
import GridViewSection from '@baserow/modules/database/components/view/grid/GridViewSection'
|
|
import HorizontalResize from '@baserow/modules/core/components/HorizontalResize'
|
|
import GridViewRowDragging from '@baserow/modules/database/components/view/grid/GridViewRowDragging'
|
|
import RowEditModal from '@baserow/modules/database/components/row/RowEditModal'
|
|
import gridViewHelpers from '@baserow/modules/database/mixins/gridViewHelpers'
|
|
import {
|
|
sortFieldsByOrderAndIdFunction,
|
|
filterVisibleFieldsFunction,
|
|
filterHiddenFieldsFunction,
|
|
} from '@baserow/modules/database/utils/view'
|
|
import viewHelpers from '@baserow/modules/database/mixins/viewHelpers'
|
|
import { isElement } from '@baserow/modules/core/utils/dom'
|
|
import viewDecoration from '@baserow/modules/database/mixins/viewDecoration'
|
|
import { populateRow } from '@baserow/modules/database/store/view/grid'
|
|
import { clone } from '@baserow/modules/core/utils/object'
|
|
import copyPasteHelper from '@baserow/modules/database/mixins/copyPasteHelper'
|
|
import GridViewRowsAddContext from '@baserow/modules/database/components/view/grid/fields/GridViewRowsAddContext'
|
|
import { copyToClipboard } from '@baserow/modules/database/utils/clipboard'
|
|
import { GRID_VIEW_SIZE_TO_ROW_HEIGHT_MAPPING } from '@baserow/modules/database/constants'
|
|
|
|
export default {
|
|
name: 'GridView',
|
|
components: {
|
|
HorizontalResize,
|
|
GridViewRowsAddContext,
|
|
GridViewSection,
|
|
GridViewRowDragging,
|
|
RowEditModal,
|
|
},
|
|
mixins: [viewHelpers, gridViewHelpers, viewDecoration, copyPasteHelper],
|
|
props: {
|
|
fields: {
|
|
type: Array,
|
|
required: true,
|
|
},
|
|
view: {
|
|
type: Object,
|
|
required: true,
|
|
},
|
|
table: {
|
|
type: Object,
|
|
required: true,
|
|
},
|
|
database: {
|
|
type: Object,
|
|
required: true,
|
|
},
|
|
readOnly: {
|
|
type: Boolean,
|
|
required: true,
|
|
},
|
|
},
|
|
data() {
|
|
return {
|
|
lastHoveredRow: null,
|
|
selectedRow: null,
|
|
deletingRow: false,
|
|
showHiddenFieldsInRowModal: false,
|
|
// Indicates whether the first two columns have enough space to be usable. If
|
|
// not, the primary field is not sticky, so it's easier to view all data on for
|
|
// example a smartphone.
|
|
canFitInTwoColumns: true,
|
|
// When a cell is selected, the component will be propagated and stored into this
|
|
// array until it's unselected. Having these components here can be useful if a
|
|
// global keyboard shortcut must be blocked if a single line text field cell is
|
|
// in an editing state for example.
|
|
selectedCellComponents: [],
|
|
}
|
|
},
|
|
computed: {
|
|
...mapGetters({
|
|
row: 'rowModalNavigation/getRow',
|
|
}),
|
|
/**
|
|
* Returns all visible fields no matter in what section they
|
|
* belong.
|
|
*/
|
|
allVisibleFields() {
|
|
return this.leftFields.concat(this.rightVisibleFields)
|
|
},
|
|
/**
|
|
* Returns only the visible fields in the correct order that are in
|
|
* the right section of the grid. Primary must always be
|
|
* first if in that list.
|
|
*/
|
|
rightVisibleFields() {
|
|
const fieldOptions = this.fieldOptions
|
|
return this.rightFields
|
|
.filter(filterVisibleFieldsFunction(fieldOptions))
|
|
.sort(sortFieldsByOrderAndIdFunction(fieldOptions, true))
|
|
},
|
|
/**
|
|
* Returns only the hidden fields in the correct order.
|
|
*/
|
|
hiddenFields() {
|
|
const fieldOptions = this.fieldOptions
|
|
return this.rightFields
|
|
.filter(filterHiddenFieldsFunction(fieldOptions))
|
|
.sort(sortFieldsByOrderAndIdFunction(fieldOptions))
|
|
},
|
|
viewHasGroupBys() {
|
|
return this.activeGroupBys.length > 0
|
|
},
|
|
primaryFieldIsSticky() {
|
|
return this.canFitInTwoColumns && !this.viewHasGroupBys
|
|
},
|
|
leftFields() {
|
|
if (this.primaryFieldIsSticky) {
|
|
return this.fields.filter((field) => field.primary)
|
|
} else {
|
|
return []
|
|
}
|
|
},
|
|
rightFields() {
|
|
if (this.primaryFieldIsSticky) {
|
|
return this.fields.filter((field) => !field.primary)
|
|
} else {
|
|
return this.fields
|
|
}
|
|
},
|
|
leftFieldsWidth() {
|
|
return this.leftFields.reduce(
|
|
(value, field) => this.getFieldWidth(field) + value,
|
|
0
|
|
)
|
|
},
|
|
leftWidth() {
|
|
return (
|
|
this.leftFieldsWidth +
|
|
(this.viewHasGroupBys ? 0 : this.gridViewRowDetailsWidth) +
|
|
// 100 must be replaced with the dynamic width
|
|
this.activeGroupByWidth
|
|
)
|
|
},
|
|
activeSearchTerm() {
|
|
return this.$store.getters[
|
|
`${this.storePrefix}view/grid/getActiveSearchTerm`
|
|
]
|
|
},
|
|
},
|
|
watch: {
|
|
fieldOptions: {
|
|
deep: true,
|
|
handler() {
|
|
// When the field options have changed, it could be that the width of the
|
|
// fields have changed and in that case we want to update the scrollbars.
|
|
this.fieldsUpdated()
|
|
},
|
|
},
|
|
fields() {
|
|
// When a field is added or removed, we want to update the scrollbars.
|
|
this.fieldsUpdated()
|
|
},
|
|
row: {
|
|
deep: true,
|
|
handler(newRow, prevRow) {
|
|
if (this.$refs.rowEditModal) {
|
|
if (
|
|
(prevRow === null && newRow !== null) ||
|
|
(prevRow && newRow && prevRow.id !== newRow.id)
|
|
) {
|
|
this.populateAndEditRow(newRow)
|
|
} else if (prevRow !== null && newRow === null) {
|
|
// Pass emit=false as argument into the hide function because that will
|
|
// prevent emitting another `hidden` event of the `RowEditModal` which can
|
|
// result in the route changing twice.
|
|
this.$refs.rowEditModal.hide(false)
|
|
}
|
|
}
|
|
// `refreshRow` doesn't immediately hide a row not matching filters if a
|
|
// user open the modal for that row to solve
|
|
// https://gitlab.com/baserow/baserow/-/issues/1765. This handler ensure
|
|
// the row is correctly refreshed if the user open another row using the
|
|
// navigation buttons in the modal.
|
|
const prevRowId = prevRow?.id
|
|
if (prevRowId !== undefined && prevRowId !== newRow?.id) {
|
|
this.$store.dispatch(this.storePrefix + 'view/grid/refreshRowById', {
|
|
grid: this.view,
|
|
fields: this.fields,
|
|
rowId: prevRowId,
|
|
getScrollTop: () => this.$refs.left.$refs.body.scrollTop,
|
|
})
|
|
}
|
|
},
|
|
},
|
|
'view.row_height_size'(value, oldValue) {
|
|
if (value === oldValue) {
|
|
return
|
|
}
|
|
this.$store.dispatch(
|
|
this.storePrefix + 'view/grid/setRowHeight',
|
|
GRID_VIEW_SIZE_TO_ROW_HEIGHT_MAPPING[value]
|
|
)
|
|
this.onWindowResize()
|
|
this.$emit('refresh')
|
|
},
|
|
},
|
|
beforeCreate() {
|
|
this.$options.computed = {
|
|
...(this.$options.computed || {}),
|
|
...mapGetters({
|
|
allRows: this.$options.propsData.storePrefix + 'view/grid/getAllRows',
|
|
isMultiSelectActive:
|
|
this.$options.propsData.storePrefix + 'view/grid/isMultiSelectActive',
|
|
}),
|
|
}
|
|
},
|
|
created() {
|
|
// When the grid view is created we want to update the scrollbars.
|
|
this.fieldsUpdated()
|
|
},
|
|
beforeMount() {
|
|
this.$bus.$on('field-deleted', this.fieldDeleted)
|
|
},
|
|
mounted() {
|
|
this.$el.resizeObserver = new ResizeObserver(this.onWindowResize)
|
|
this.$el.resizeObserver.observe(this.$el)
|
|
window.addEventListener('keydown', this.keyDownEvent)
|
|
window.addEventListener('copy', this.copySelection)
|
|
window.addEventListener('paste', this.pasteFromMultipleCellSelection)
|
|
window.addEventListener('click', this.cancelMultiSelectIfActive)
|
|
window.addEventListener('mouseup', this.multiSelectStop)
|
|
this.$refs.left.$el.addEventListener(
|
|
'scroll',
|
|
this.$el.horizontalScrollEvent
|
|
)
|
|
this.$store.dispatch(
|
|
this.storePrefix + 'view/grid/fetchAllFieldAggregationData',
|
|
{ view: this.view }
|
|
)
|
|
this.onWindowResize()
|
|
|
|
if (this.row !== null) {
|
|
this.populateAndEditRow(this.row)
|
|
}
|
|
},
|
|
beforeDestroy() {
|
|
this.$el.resizeObserver.unobserve(this.$el)
|
|
window.removeEventListener('keydown', this.keyDownEvent)
|
|
window.removeEventListener('copy', this.copySelection)
|
|
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/clearAndDisableMultiSelect'
|
|
)
|
|
},
|
|
methods: {
|
|
/**
|
|
* Method to scroll viewport to a DOM element
|
|
* Scroll direction can be limited to only one axis (both, vertical, horizontal)
|
|
*/
|
|
scrollToCellElement(element, scrollDirection = 'both', field) {
|
|
const verticalContainer = this.$refs.right.$refs.body
|
|
const horizontalContainer = this.$refs.right.$el
|
|
const verticalContainerRect = verticalContainer.getBoundingClientRect()
|
|
const horizontalContainerRect =
|
|
horizontalContainer.getBoundingClientRect()
|
|
const elementRect = element.getBoundingClientRect()
|
|
const elementTop = elementRect.top - verticalContainerRect.top
|
|
const elementBottom = elementRect.bottom - verticalContainerRect.top
|
|
const elementLeft = elementRect.left - horizontalContainerRect.left
|
|
const elementRight = elementRect.right - horizontalContainerRect.left
|
|
this.scrollToElementRect(
|
|
{ elementTop, elementBottom, elementLeft, elementRight },
|
|
scrollDirection,
|
|
field
|
|
)
|
|
},
|
|
/**
|
|
* Method to scroll viewport to a DOM element defined by its rectangle
|
|
* Scroll direction can be limited to only one axis (both, vertical, horizontal)
|
|
*/
|
|
scrollToElementRect(
|
|
{ elementTop, elementBottom, elementLeft, elementRight },
|
|
scrollDirection = 'both',
|
|
field
|
|
) {
|
|
const verticalContainer = this.$refs.right.$refs.body
|
|
const horizontalContainer = this.$refs.right.$el
|
|
const verticalContainerHeight = verticalContainer.clientHeight
|
|
const horizontalContainerWidth = horizontalContainer.clientWidth
|
|
|
|
if (scrollDirection !== 'horizontal') {
|
|
if (elementTop < 0) {
|
|
// If the field isn't visible in the viewport we need to scroll up in order
|
|
// to show it.
|
|
this.verticalScroll(elementTop + verticalContainer.scrollTop - 20)
|
|
this.$refs.scrollbars.updateVertical()
|
|
} else if (elementBottom > verticalContainerHeight) {
|
|
// If the field isn't visible in the viewport we need to scroll down in order
|
|
// to show it.
|
|
this.verticalScroll(
|
|
elementBottom +
|
|
verticalContainer.scrollTop -
|
|
verticalContainer.clientHeight +
|
|
20
|
|
)
|
|
this.$refs.scrollbars.updateVertical()
|
|
}
|
|
}
|
|
|
|
if (scrollDirection !== 'vertical') {
|
|
const fieldPrimary = field.primary
|
|
if (elementLeft < 0 && (!this.primaryFieldIsSticky || !fieldPrimary)) {
|
|
// If the field isn't visible in the viewport we need to scroll left in order
|
|
// to show it.
|
|
this.horizontalScroll(
|
|
elementLeft + horizontalContainer.scrollLeft - 20
|
|
)
|
|
this.$refs.scrollbars.updateHorizontal()
|
|
} else if (
|
|
elementRight > horizontalContainerWidth &&
|
|
(!this.primaryFieldIsSticky || !fieldPrimary)
|
|
) {
|
|
// If the field isn't visible in the viewport we need to scroll right in order
|
|
// to show it.
|
|
this.horizontalScroll(
|
|
elementRight +
|
|
horizontalContainer.scrollLeft -
|
|
horizontalContainer.clientWidth +
|
|
20
|
|
)
|
|
this.$refs.scrollbars.updateHorizontal()
|
|
}
|
|
}
|
|
},
|
|
duplicateSelectedRow(event, selectedRow) {
|
|
event.preventFieldCellUnselect = true
|
|
this.addRowAfter(selectedRow, selectedRow)
|
|
this.$refs.rowContext.hide()
|
|
},
|
|
copyLinkToSelectedRow(event, selectedRow) {
|
|
const url =
|
|
this.$config.BASEROW_EMBEDDED_SHARE_URL +
|
|
this.$router.resolve({
|
|
name: 'database-table-row',
|
|
params: { ...this.$route.params, rowId: selectedRow.id },
|
|
}).href
|
|
copyToClipboard(url)
|
|
this.$store.dispatch('toast/info', {
|
|
title: this.$i18n.t('gridView.copiedRowURL'),
|
|
message: this.$i18n.t('gridView.copiedRowURLMessage', {
|
|
id: selectedRow.id,
|
|
}),
|
|
})
|
|
this.$refs.rowContext.hide()
|
|
},
|
|
addRowAboveSelectedRow(event, selectedRow) {
|
|
event.preventFieldCellUnselect = true
|
|
this.addRow(selectedRow)
|
|
this.$refs.rowContext.hide()
|
|
},
|
|
addRowBelowSelectedRow(event, selectedRow) {
|
|
event.preventFieldCellUnselect = true
|
|
this.addRowAfter(selectedRow)
|
|
this.$refs.rowContext.hide()
|
|
},
|
|
/**
|
|
* When a field is deleted we need to check if that field was related to any
|
|
* filters or sortings. If that is the case then the view needs to be refreshed so
|
|
* we can see fresh results.
|
|
*/
|
|
fieldDeleted({ field }) {
|
|
const filterIndex = this.view.filters.findIndex((filter) => {
|
|
return filter.field === field.id
|
|
})
|
|
const groupIndex = this.view.group_bys.findIndex((group) => {
|
|
return group.field === field.id
|
|
})
|
|
const sortIndex = this.view.sortings.findIndex((sort) => {
|
|
return sort.field === field.id
|
|
})
|
|
if (filterIndex > -1 || groupIndex > -1 || sortIndex > -1) {
|
|
this.$emit('refresh')
|
|
}
|
|
},
|
|
/**
|
|
* Is called when anything related to a field has changed and in that case we want
|
|
* to update the scrollbars.
|
|
*/
|
|
fieldsUpdated() {
|
|
const scrollbars = this.$refs.scrollbars
|
|
// Vue can sometimes trigger this via watch before the child component
|
|
// scrollbars has been created, check it exists and has the expected method
|
|
if (scrollbars && scrollbars.update) {
|
|
scrollbars.update()
|
|
}
|
|
|
|
// When anything related to the fields has been updated, it could be that it
|
|
// doesn't fit in two columns anymore. Calling this method checks that.
|
|
this.checkCanFitInTwoColumns()
|
|
},
|
|
/**
|
|
* Calls action in the store to refresh row directly from the backend - f. ex.
|
|
* when editing row from a different table, when editing is complete, we need
|
|
* to refresh the 'main' row that's 'under' the RowEdit modal.
|
|
*/
|
|
async refreshRow(row) {
|
|
try {
|
|
await this.$store.dispatch(
|
|
this.storePrefix + 'view/grid/refreshRowFromBackend',
|
|
{
|
|
table: this.table,
|
|
row,
|
|
}
|
|
)
|
|
} catch (error) {
|
|
notifyIf(error, 'row')
|
|
}
|
|
},
|
|
/**
|
|
* Called when a cell value has been updated. This can for example happen via the
|
|
* row edit modal or when editing a cell directly in the grid.
|
|
*/
|
|
async updateValue({ field, row, value, oldValue }) {
|
|
try {
|
|
await this.$store.dispatch(
|
|
this.storePrefix + 'view/grid/updateRowValue',
|
|
{
|
|
table: this.table,
|
|
view: this.view,
|
|
fields: this.fields,
|
|
row,
|
|
field,
|
|
value,
|
|
oldValue,
|
|
}
|
|
)
|
|
} catch (error) {
|
|
notifyIf(error, 'field')
|
|
}
|
|
},
|
|
/**
|
|
* Called when a value is edited, but not yet saved. Here we can do a preliminary
|
|
* check to see if the values matches the filters.
|
|
*/
|
|
editValue({ field, row, value, oldValue }) {
|
|
const overrides = {}
|
|
overrides[`field_${field.id}`] = value
|
|
this.$store.dispatch(this.storePrefix + 'view/grid/onRowChange', {
|
|
view: this.view,
|
|
row,
|
|
fields: this.fields,
|
|
overrides,
|
|
})
|
|
},
|
|
/**
|
|
* This method is called by the Scrollbars component and should return the element
|
|
* that handles the horizontal scrolling.
|
|
*/
|
|
getHorizontalScrollbarElement() {
|
|
return this.$refs.right.$el
|
|
},
|
|
/**
|
|
* This method is called by the Scrollbars component and should return the element
|
|
* that handles the vertical scrolling.
|
|
*/
|
|
getVerticalScrollbarElement() {
|
|
return this.$refs.right.$refs.body
|
|
},
|
|
/**
|
|
* Called when a user scrolls without using the scrollbar.
|
|
*/
|
|
scroll(pixelY, pixelX) {
|
|
const $rightBody = this.$refs.right.$refs.body
|
|
const $right = this.$refs.right.$el
|
|
|
|
const top = $rightBody.scrollTop + pixelY
|
|
const left = $right.scrollLeft + pixelX
|
|
|
|
this.verticalScroll(top)
|
|
this.horizontalScroll(left)
|
|
|
|
this.$refs.scrollbars.update()
|
|
},
|
|
/**
|
|
* Called when the user scrolls vertically. The scroll offset of both the left and
|
|
* right section must be updated and we want might need to fetch new rows which
|
|
* is handled by the grid view store.
|
|
*/
|
|
verticalScroll(top) {
|
|
this.$refs.left.$refs.body.scrollTop = top
|
|
this.$refs.right.$refs.body.scrollTop = top
|
|
|
|
this.$store.dispatch(
|
|
this.storePrefix + 'view/grid/fetchByScrollTopDelayed',
|
|
{
|
|
scrollTop: this.$refs.left.$refs.body.scrollTop,
|
|
fields: this.fields,
|
|
}
|
|
)
|
|
},
|
|
/**
|
|
* Called when the user scrolls horizontally. If the user scrolls we might want to
|
|
* show a shadow next to the left section because that one has a fixed position.
|
|
*/
|
|
horizontalScroll(left) {
|
|
const $right = this.$refs.right.$el
|
|
const $divider = this.$refs.divider
|
|
const canScroll = $right.scrollWidth > $right.clientWidth
|
|
|
|
$divider.classList.toggle('shadow', canScroll && left > 0)
|
|
$right.scrollLeft = left
|
|
},
|
|
/**
|
|
* Selects the entire row.
|
|
*/
|
|
async selectRow(event, row) {
|
|
event.stopPropagation()
|
|
const rowIndex = this.$store.getters[
|
|
this.storePrefix + 'view/grid/getRowIndexById'
|
|
](row.id)
|
|
await this.$store.dispatch(
|
|
this.storePrefix + 'view/grid/setMultipleSelect',
|
|
{
|
|
rowHeadIndex: rowIndex,
|
|
rowTailIndex: rowIndex,
|
|
fieldHeadIndex: 0,
|
|
fieldTailIndex: this.allVisibleFields.length - 1,
|
|
}
|
|
)
|
|
},
|
|
async addRow(before = null, values = {}) {
|
|
try {
|
|
await this.$store.dispatch(
|
|
this.storePrefix + 'view/grid/createNewRow',
|
|
{
|
|
view: this.view,
|
|
table: this.table,
|
|
// We need a list of all fields including the primary one here.
|
|
fields: this.fields,
|
|
values,
|
|
before,
|
|
selectPrimaryCell: true,
|
|
}
|
|
)
|
|
} catch (error) {
|
|
notifyIf(error, 'row')
|
|
}
|
|
},
|
|
async addRows(rowsAmount) {
|
|
this.$refs.rowsAddContext.hide()
|
|
try {
|
|
await this.$store.dispatch(
|
|
this.storePrefix + 'view/grid/createNewRows',
|
|
{
|
|
view: this.view,
|
|
table: this.table,
|
|
// We need a list of all fields including the primary one here.
|
|
fields: this.fields,
|
|
rows: Array.from(Array(rowsAmount)).map(() => ({})),
|
|
selectPrimaryCell: true,
|
|
}
|
|
)
|
|
} catch (error) {
|
|
notifyIf(error, 'row')
|
|
}
|
|
},
|
|
/**
|
|
* Because it is only possible to add a new row before another row, we have to
|
|
* figure out which row is below the given row and insert before that one. If the
|
|
* next row is not found, we can safely assume it is the last row and add it last.
|
|
*/
|
|
addRowAfter(row, values = {}) {
|
|
const rows =
|
|
this.$store.getters[this.storePrefix + 'view/grid/getAllRows']
|
|
const index = rows.findIndex((r) => r.id === row.id)
|
|
let nextRow = null
|
|
|
|
if (index !== -1 && rows.length > index + 1) {
|
|
nextRow = rows[index + 1]
|
|
}
|
|
|
|
this.addRow(nextRow, values)
|
|
},
|
|
async deleteRow(row) {
|
|
try {
|
|
this.$refs.rowContext.hide()
|
|
// We need a small helper function that calculates the current scrollTop because
|
|
// the delete action will recalculate the visible scroll range and buffer.
|
|
const getScrollTop = () => this.$refs.left.$refs.body.scrollTop
|
|
await this.$store.dispatch(
|
|
this.storePrefix + 'view/grid/deleteExistingRow',
|
|
{
|
|
table: this.table,
|
|
view: this.view,
|
|
fields: this.fields,
|
|
row,
|
|
getScrollTop,
|
|
}
|
|
)
|
|
await this.$store.dispatch('toast/restore', {
|
|
trash_item_type: 'row',
|
|
parent_trash_item_id: this.table.id,
|
|
trash_item_id: row.id,
|
|
})
|
|
} catch (error) {
|
|
notifyIf(error, 'row')
|
|
}
|
|
},
|
|
setRowHover(row, value) {
|
|
// Sometimes the mouseleave is not triggered, but because you can hover only one
|
|
// row at a time we can remember which was hovered last and set the hover state to
|
|
// false if it differs.
|
|
if (this.lastHoveredRow !== null && this.lastHoveredRow.id !== row.id) {
|
|
this.$store.dispatch(this.storePrefix + 'view/grid/setRowHover', {
|
|
row: this.lastHoveredRow,
|
|
value: false,
|
|
})
|
|
this.lastHoveredRow = null
|
|
}
|
|
|
|
this.$store.dispatch(this.storePrefix + 'view/grid/setRowHover', {
|
|
row,
|
|
value,
|
|
})
|
|
this.lastHoveredRow = row
|
|
},
|
|
showRowContext(event, row) {
|
|
this.selectedRow = row
|
|
this.$refs.rowContext.toggleNextToMouse(event)
|
|
},
|
|
/**
|
|
* Called when the user starts dragging the row. This will initiate the dragging
|
|
* effect and allows the user to move it to another position.
|
|
*/
|
|
rowDragStart({ event, row }) {
|
|
this.$refs.rowDragging.start(row, event)
|
|
},
|
|
/**
|
|
* When the modal hides and the related row does not match the filters anymore it
|
|
* must be deleted.
|
|
*/
|
|
rowEditModalHidden({ row }) {
|
|
this.$emit('selected-row', undefined)
|
|
|
|
// It could be that the row is not in the buffer anymore and in that case we also
|
|
// don't need to refresh the row.
|
|
if (
|
|
row === undefined ||
|
|
!Object.prototype.hasOwnProperty.call(row, 'id')
|
|
) {
|
|
return
|
|
}
|
|
|
|
this.$store.dispatch(this.storePrefix + 'view/grid/refreshRowById', {
|
|
grid: this.view,
|
|
fields: this.fields,
|
|
rowId: row.id,
|
|
getScrollTop: () => this.$refs.left.$refs.body.scrollTop,
|
|
})
|
|
},
|
|
/**
|
|
* When the row edit modal is opened we notify
|
|
* the Table component that a new row has been selected,
|
|
* such that we can update the path to include the row id.
|
|
*/
|
|
openRowEditModal(row) {
|
|
// The row edit modal doesn't support a row that doesn't yet have a row ID, so we
|
|
// don't do anything in that case.
|
|
if (row._.loading) {
|
|
return
|
|
}
|
|
|
|
this.$refs.rowEditModal.show(row.id)
|
|
this.$emit('selected-row', row)
|
|
},
|
|
/**
|
|
* Populates a new row and opens the row edit modal
|
|
* to edit the row.
|
|
*/
|
|
populateAndEditRow(row) {
|
|
const rowClone = populateRow(clone(row))
|
|
this.$refs.rowEditModal.show(row.id, rowClone)
|
|
},
|
|
/**
|
|
* When a cell is selected we want to make sure it is visible in the viewport, so
|
|
* we might need to scroll a little bit.
|
|
*/
|
|
selectedCell({ component, row, field }) {
|
|
// Put the selected cell component in an array, so that we can check whether it's
|
|
// allowed to hit keyboard shortcuts, click outside, etc when a global keyboard
|
|
// short is called.
|
|
if (!this.selectedCellComponents.includes(component)) {
|
|
this.selectedCellComponents.push(component)
|
|
}
|
|
|
|
const element = component.$el
|
|
this.scrollToCellElement(element, 'both', field)
|
|
|
|
this.$store.dispatch(this.storePrefix + 'view/grid/addRowSelectedBy', {
|
|
row,
|
|
field,
|
|
})
|
|
},
|
|
/**
|
|
* When a cell is unselected need to change the selected state of the row.
|
|
*/
|
|
unselectedCell({ component, row, field }) {
|
|
// Remove the selected cell component in an array because we don't have to check
|
|
// if keyboard shortcuts are allowed, click outside, etc is allowed anymore.
|
|
if (this.selectedCellComponents.includes(component)) {
|
|
const index = this.selectedCellComponents.indexOf(component)
|
|
this.selectedCellComponents.splice(index, 1)
|
|
}
|
|
|
|
// We want to change selected state of the row on the next tick because if another
|
|
// cell within a row is selected, we want to wait for that selected state tot
|
|
// change. This will make sure that the row is stays selected.
|
|
this.$nextTick(() => {
|
|
// The getScrollTop function tries to find the vertically scrollable element
|
|
// and returns the scrollTop value. The unselectCell method could in some cases
|
|
// be called when the grid view component has already been destroyed. For
|
|
// example when a cell is selected in the template modal and the user presses
|
|
// the escape key which destroys the modal. We need to make sure, the lookup
|
|
// doesn't fail hard when that happens, so we can return the last scroll top
|
|
// value stored in the grid view store.
|
|
let getScrollTop = () => this.$refs.left.$refs.body.scrollTop
|
|
if (!this.$refs.left) {
|
|
getScrollTop = () =>
|
|
this.$store.getters[this.storePrefix + 'view/grid/getScrollTop']
|
|
}
|
|
|
|
this.$store.dispatch(
|
|
this.storePrefix + 'view/grid/removeRowSelectedBy',
|
|
{
|
|
grid: this.view,
|
|
fields: this.fields,
|
|
row,
|
|
field,
|
|
getScrollTop,
|
|
isRowOpenedInModal: this.rowOpenedInModal?.id === row.id,
|
|
}
|
|
)
|
|
})
|
|
},
|
|
/**
|
|
* This method is called when the next cell must be selected. This can for example
|
|
* happen when the tab key is pressed. It tries to find the next field based on the
|
|
* direction and will select that one.
|
|
*/
|
|
selectNextCell({ row, field, direction = 'next' }) {
|
|
const fields = this.allVisibleFields
|
|
let nextFieldId = -1
|
|
let nextRowId = -1
|
|
|
|
if (direction === 'next' || direction === 'previous') {
|
|
nextRowId = row.id
|
|
|
|
// First we need to know which index the currently selected field has in the
|
|
// fields list.
|
|
const index = fields.findIndex((f) => f.id === field.id)
|
|
if (direction === 'next' && fields.length > index + 1) {
|
|
// If we want to select the next field we can just check if the next index
|
|
// exists and read the id from there.
|
|
nextFieldId = fields[index + 1].id
|
|
} else if (direction === 'previous' && index > 0) {
|
|
// If we want to select the previous field we can just check if aren't
|
|
// already the first and read the id from the previous.
|
|
nextFieldId = fields[index - 1].id
|
|
}
|
|
}
|
|
|
|
if (direction === 'below' || direction === 'above') {
|
|
nextFieldId = field.id
|
|
const rows =
|
|
this.$store.getters[this.storePrefix + 'view/grid/getAllRows']
|
|
const index = rows.findIndex((r) => r.id === row.id)
|
|
|
|
if (index !== -1 && direction === 'below' && rows.length > index + 1) {
|
|
// If the next row index exists we can select the same field in the next row.
|
|
nextRowId = rows[index + 1].id
|
|
} else if (index !== -1 && direction === 'above' && index > 0) {
|
|
// If the previous row index exists we can select the same field in the
|
|
// previous row.
|
|
nextRowId = rows[index - 1].id
|
|
}
|
|
}
|
|
|
|
if (nextFieldId === -1 || nextRowId === -1) {
|
|
return
|
|
}
|
|
|
|
this.$store.dispatch(
|
|
this.storePrefix + 'view/grid/clearAndDisableMultiSelect'
|
|
)
|
|
|
|
this.$store.dispatch(this.storePrefix + 'view/grid/setSelectedCell', {
|
|
rowId: nextRowId,
|
|
fieldId: nextFieldId,
|
|
fields: this.fields,
|
|
})
|
|
},
|
|
cellSelected({ fieldId, rowId }) {
|
|
this.$store.dispatch(this.storePrefix + 'view/grid/setSelectedCell', {
|
|
rowId,
|
|
fieldId,
|
|
fields: this.fields,
|
|
})
|
|
},
|
|
/**
|
|
* This method is called from the parent component when the data in the view has
|
|
* been reset. This can for example happen when a user creates or updates a filter
|
|
* or wants to sort on a field.
|
|
*/
|
|
async refresh() {
|
|
await this.$store.dispatch(
|
|
this.storePrefix + 'view/grid/visibleByScrollTop',
|
|
this.$refs.right.$refs.body.scrollTop
|
|
)
|
|
// The grid view store keeps a copy of the group bys that must only be updated
|
|
// after the refresh of the page. This is because the group by depends on the rows
|
|
// being sorted, and this will only be the case after a refresh.
|
|
await this.$store.dispatch(
|
|
this.storePrefix + 'view/grid/updateActiveGroupBys',
|
|
clone(this.view.group_bys)
|
|
)
|
|
this.$nextTick(() => {
|
|
this.fieldsUpdated()
|
|
})
|
|
},
|
|
multiSelectShiftClick({ event, row, field }) {
|
|
this.$store.dispatch(
|
|
this.storePrefix + 'view/grid/multiSelectShiftClick',
|
|
{
|
|
rowId: row.id,
|
|
fieldIndex: this.allVisibleFields.findIndex((f) => f.id === field.id),
|
|
}
|
|
)
|
|
},
|
|
/**
|
|
* 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 }) {
|
|
const fieldIndex = this.allVisibleFields.findIndex(
|
|
(f) => f.id === field.id
|
|
)
|
|
|
|
this.$store.dispatch(this.storePrefix + 'view/grid/multiSelectStart', {
|
|
rowId: row.id,
|
|
fieldIndex,
|
|
})
|
|
this.$refs.rowContext.hide()
|
|
},
|
|
/**
|
|
* 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 }) {
|
|
const fieldIndex = this.allVisibleFields.findIndex(
|
|
(f) => f.id === field.id
|
|
)
|
|
|
|
this.$store.dispatch(this.storePrefix + 'view/grid/multiSelectHold', {
|
|
rowId: row.id,
|
|
fieldIndex,
|
|
})
|
|
},
|
|
/**
|
|
* 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.
|
|
*/
|
|
cancelMultiSelectIfActive(event) {
|
|
if (
|
|
this.$store.getters[
|
|
this.storePrefix + 'view/grid/isMultiSelectActive'
|
|
] &&
|
|
!event.shiftKey &&
|
|
(!isElement(this.$el, event.target) ||
|
|
!['grid-view__row', 'grid-view__rows', 'grid-view'].includes(
|
|
event.target.classList[0]
|
|
))
|
|
) {
|
|
this.$store.dispatch(
|
|
this.storePrefix + 'view/grid/clearAndDisableMultiSelect'
|
|
)
|
|
}
|
|
},
|
|
async keyDownEvent(event) {
|
|
const arrowKeys = ['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown']
|
|
const arrowShiftKeysMapping = {
|
|
ArrowLeft: 'previous',
|
|
ArrowRight: 'next',
|
|
ArrowUp: 'above',
|
|
ArrowDown: 'below',
|
|
}
|
|
const { key, shiftKey } = event
|
|
if (
|
|
arrowKeys.includes(key) &&
|
|
shiftKey &&
|
|
// Only allow this event if there is an active single cell selection, or
|
|
// multiple selection.
|
|
(this.$store.getters[this.storePrefix + 'view/grid/hasSelectedCell'] ||
|
|
this.$store.getters[
|
|
this.storePrefix + 'view/grid/isMultiSelectActive'
|
|
]) &&
|
|
// And there is no selected cell component blocking the select next events. A
|
|
// single line text field can for example block this while it's in an editing
|
|
// state.
|
|
this.selectedCellComponents.every((component) => {
|
|
return component.canSelectNext(event)
|
|
})
|
|
) {
|
|
event.preventDefault()
|
|
|
|
const { position, fieldIndex, rowIndex } = await this.$store.dispatch(
|
|
this.storePrefix + 'view/grid/multiSelectShiftChange',
|
|
{
|
|
direction: arrowShiftKeysMapping[key],
|
|
}
|
|
)
|
|
|
|
if (position === null) {
|
|
return
|
|
}
|
|
|
|
let scrollDirection = 'both'
|
|
if (position === 'head' && key === 'ArrowLeft') {
|
|
scrollDirection = 'horizontal'
|
|
}
|
|
if (position === 'head' && key === 'ArrowUp') {
|
|
scrollDirection = 'vertical'
|
|
}
|
|
if (position === 'tail' && key === 'ArrowRight') {
|
|
scrollDirection = 'horizontal'
|
|
}
|
|
if (position === 'tail' && key === 'ArrowDown') {
|
|
scrollDirection = 'vertical'
|
|
}
|
|
|
|
const fieldId = this.$store.getters[
|
|
this.storePrefix + 'view/grid/getFieldIdByIndex'
|
|
](fieldIndex, this.fields)
|
|
if (fieldId === -1) {
|
|
return
|
|
}
|
|
const field = this.$store.getters['field/get'](fieldId)
|
|
const verticalContainer = this.$refs.right.$refs.body
|
|
const horizontalContainer = this.$refs.right.$el
|
|
const visibleFieldOptions = this.$store.getters[
|
|
this.storePrefix + 'view/grid/getOrderedVisibleFieldOptions'
|
|
](this.fields)
|
|
let elementRight = -horizontalContainer.scrollLeft
|
|
for (let i = 0; i < visibleFieldOptions.length; i++) {
|
|
const fieldOption = visibleFieldOptions[i]
|
|
if (i === 0) {
|
|
if (fieldOption[0] === fieldId) {
|
|
elementRight = 0
|
|
break
|
|
}
|
|
continue
|
|
}
|
|
elementRight += this.getFieldWidth(this.fields[fieldOption[0]])
|
|
if (fieldOption[0] === fieldId) {
|
|
break
|
|
}
|
|
}
|
|
const rowHeight =
|
|
this.$store.getters[this.storePrefix + 'view/grid/getRowHeight']
|
|
const elementLeft =
|
|
elementRight - this.getFieldWidth(this.fields[fieldId])
|
|
const elementBottom =
|
|
-verticalContainer.scrollTop + rowHeight + rowIndex * rowHeight
|
|
const elementTop = elementBottom - rowHeight
|
|
this.scrollToElementRect(
|
|
{ elementTop, elementBottom, elementLeft, elementRight },
|
|
scrollDirection,
|
|
field
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
if (
|
|
this.$store.getters[this.storePrefix + 'view/grid/isMultiSelectActive']
|
|
) {
|
|
if (arrowKeys.includes(key) && !shiftKey) {
|
|
this.$store.dispatch(
|
|
this.storePrefix + 'view/grid/setSelectedCellCancelledMultiSelect',
|
|
{
|
|
direction: arrowShiftKeysMapping[key],
|
|
fields: this.fields,
|
|
}
|
|
)
|
|
}
|
|
|
|
if (event.key === 'Backspace' || event.key === 'Delete') {
|
|
this.clearValuesFromMultipleCellSelection()
|
|
}
|
|
}
|
|
},
|
|
/**
|
|
* Prepare and copy the multi-select cells into the clipboard,
|
|
* formatted as TSV
|
|
*/
|
|
async copySelection(event, includeHeader = false) {
|
|
const gridStore = this.storePrefix + 'view/grid'
|
|
if (!this.$store.getters[`${gridStore}/isMultiSelectActive`]) {
|
|
return
|
|
}
|
|
try {
|
|
this.$store.dispatch('toast/setCopying', true)
|
|
await this.copySelectionToClipboard(
|
|
this.$store.dispatch(`${gridStore}/getCurrentSelection`, {
|
|
fields: this.allVisibleFields,
|
|
}),
|
|
includeHeader
|
|
)
|
|
} catch (error) {
|
|
notifyIf(error, 'view')
|
|
} finally {
|
|
this.$store.dispatch('toast/setCopying', false)
|
|
// prevent Safari from beeping since window.getSelection() is empty
|
|
event.preventDefault()
|
|
}
|
|
},
|
|
/**
|
|
* 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: { textData, jsonData }, field, row }) {
|
|
const rowIndex = this.$store.getters[
|
|
this.storePrefix + 'view/grid/getRowIndexById'
|
|
](row.id)
|
|
const fieldIndex = this.allVisibleFields.findIndex(
|
|
(f) => f.id === field.id
|
|
)
|
|
await this.pasteData(textData, jsonData, 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 [textData, jsonData] = await this.extractClipboardData(event)
|
|
|
|
await this.pasteData(textData, jsonData)
|
|
},
|
|
/**
|
|
* 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(textData, jsonData, 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 or if we don't have the
|
|
// permission, we can't paste so not doing anything.
|
|
if (
|
|
textData.length === 0 ||
|
|
textData[0].length === 0 ||
|
|
this.readOnly ||
|
|
!this.$hasPermission(
|
|
'database.table.update_row',
|
|
this.table,
|
|
this.database.workspace.id
|
|
)
|
|
) {
|
|
return
|
|
}
|
|
|
|
// The backend will fail hard if it tries to update more rows than the limit, so
|
|
// we're slicing the data here.
|
|
const pageSizeLimit = this.$config.BASEROW_ROW_PAGE_SIZE_LIMIT
|
|
if (textData.length > pageSizeLimit) {
|
|
this.$store.dispatch('toast/info', {
|
|
title: this.$t('gridView.tooManyItemsTitle'),
|
|
message: this.$t('gridView.tooManyItemsDescription', {
|
|
limit: pageSizeLimit,
|
|
}),
|
|
})
|
|
textData = textData.slice(0, pageSizeLimit)
|
|
}
|
|
|
|
this.$store.dispatch('toast/setPasting', true)
|
|
try {
|
|
await this.$store.dispatch(
|
|
this.storePrefix + 'view/grid/updateDataIntoCells',
|
|
{
|
|
table: this.table,
|
|
view: this.view,
|
|
allVisibleFields: this.allVisibleFields,
|
|
allFieldsInTable: this.fields,
|
|
getScrollTop: () => this.$refs.left.$refs.body.scrollTop,
|
|
textData,
|
|
jsonData,
|
|
rowIndex,
|
|
fieldIndex,
|
|
}
|
|
)
|
|
} catch (error) {
|
|
notifyIf(error)
|
|
}
|
|
|
|
this.$store.dispatch('toast/setPasting', false)
|
|
return true
|
|
},
|
|
/**
|
|
* Called when the delete option is selected in
|
|
* the context menu. Attempts to delete all the
|
|
* selected rows and scrolls the view accordingly.
|
|
*/
|
|
async deleteRowsFromMultipleCellSelection() {
|
|
this.deletingRow = true
|
|
try {
|
|
await this.$store.dispatch(
|
|
this.storePrefix + 'view/grid/deleteSelectedRows',
|
|
{
|
|
table: this.table,
|
|
view: this.view,
|
|
fields: this.allVisibleFields,
|
|
getScrollTop: () => this.$refs.left.$refs.body.scrollTop,
|
|
}
|
|
)
|
|
this.$refs.rowContext.hide()
|
|
} catch (error) {
|
|
notifyIf(error)
|
|
}
|
|
this.deletingRow = false
|
|
return true
|
|
},
|
|
/**
|
|
* Called when the backspace key is pressed while multi-cell select is active.
|
|
* Clears the values of all selected cells by updating them to their null values.
|
|
*/
|
|
async clearValuesFromMultipleCellSelection() {
|
|
try {
|
|
this.$store.dispatch('toast/setClearing', true)
|
|
|
|
await this.$store.dispatch(
|
|
this.storePrefix + 'view/grid/clearValuesFromMultipleCellSelection',
|
|
{
|
|
table: this.table,
|
|
view: this.view,
|
|
allVisibleFields: this.allVisibleFields,
|
|
allFieldsInTable: this.fields,
|
|
getScrollTop: () => this.$refs.left.$refs.body.scrollTop,
|
|
}
|
|
)
|
|
} catch (error) {
|
|
notifyIf(error, 'view')
|
|
} finally {
|
|
this.$store.dispatch('toast/setClearing', false)
|
|
}
|
|
},
|
|
/**
|
|
* This method figures out whether the first two columns have enough space to be
|
|
* usable using the primary field width. It updates the `canFitInTwoColumns`
|
|
* property accordingly.
|
|
*/
|
|
checkCanFitInTwoColumns() {
|
|
// In some cases this method is called when the component hasn't fully been
|
|
// loaded. This will make sure we don't change the state before that initial load.
|
|
if (!this.$el) {
|
|
return
|
|
}
|
|
|
|
// We're using `allVisibleFields` because it shouldn't matter if the primary
|
|
// field is in the left or right section.
|
|
const primary = this.allVisibleFields.find((f) => f.primary)
|
|
const maxWidth =
|
|
this.gridViewRowDetailsWidth +
|
|
(primary ? this.getFieldWidth(primary) : 0) +
|
|
300
|
|
|
|
this.canFitInTwoColumns = this.$el.clientWidth > maxWidth
|
|
},
|
|
/**
|
|
* Event called when the grid view element window resizes.
|
|
*/
|
|
onWindowResize() {
|
|
this.checkCanFitInTwoColumns()
|
|
|
|
// Update the window height to dynamically show the right amount of rows.
|
|
const height = this.$refs.left.$refs.body.clientHeight
|
|
this.$store.dispatch(
|
|
this.storePrefix + 'view/grid/setWindowHeight',
|
|
height
|
|
)
|
|
},
|
|
/**
|
|
* Called when the user right clicks after selecting multiple cells.
|
|
* Shows the context menu with the appropriate options.
|
|
*/
|
|
getMultiSelectContextItems() {
|
|
const selectedField = this.getSelectedField()
|
|
if (selectedField) {
|
|
const fieldType = this.$registry.get('field', selectedField.type)
|
|
return fieldType.getGridViewContextItemsOnCellsSelection(selectedField)
|
|
} else {
|
|
return []
|
|
}
|
|
},
|
|
/**
|
|
* Returns the selected field if only one field is selected, otherwise returns null.
|
|
*/
|
|
getSelectedField() {
|
|
const selectedFields = this.$store.getters[
|
|
this.storePrefix + 'view/grid/getSelectedFields'
|
|
](this.fields)
|
|
return selectedFields.length === 1 ? selectedFields[0] : null
|
|
},
|
|
async getSelectedRowsFunction() {
|
|
const fieldsAndRows = await this.$store.dispatch(
|
|
this.storePrefix + 'view/grid/getCurrentSelection',
|
|
{ fields: this.fields }
|
|
)
|
|
return fieldsAndRows[1]
|
|
},
|
|
},
|
|
}
|
|
</script>
|