1
0
mirror of https://gitlab.com/bramw/baserow.git synced 2024-11-25 00:46:46 +00:00
bramw_baserow/web-frontend/modules/database/components/view/gallery/GalleryView.vue
2024-09-24 15:06:35 +00:00

512 lines
15 KiB
Vue

<template>
<div class="gallery-view">
<ButtonFloating
v-if="
!readOnly &&
// Can't create rows in a table data sync table.
!table.data_sync &&
$hasPermission(
'database.table.create_row',
table,
database.workspace.id
)
"
icon="iconoir-plus"
position="fixed"
@click="$refs.rowCreateModal.show()"
>
</ButtonFloating>
<div
ref="scroll"
v-auto-scroll="{
enabled: () => dragAndDropDraggingRow !== null,
speed: 5,
padding: 20,
}"
class="gallery-view__scroll"
>
<div
class="gallery-view__cards"
:class="{
'gallery-view__cards--dragging': dragAndDropDraggingRow !== null,
}"
:style="{
height: height + 'px',
}"
>
<RowCard
v-for="slot in buffer"
v-show="slot.item !== undefined"
:key="'card-' + slot.id"
:fields="cardFields"
:row="slot.item || {}"
:workspace-id="database.workspace.id"
:loading="slot.item === null"
:cover-image-field="coverImageField"
:decorations-by-place="decorationsByPlace"
class="gallery-view__card"
:style="{
width: cardWidth + 'px',
height: slot.item === null ? cardHeight + 'px' : undefined,
transform: `translateX(${slot.position.left || 0}px) translateY(${
slot.position.top || 0
}px)`,
}"
:class="{
'gallery-view__card--dragging': slot.item && slot.item._.dragging,
}"
@mousedown="
rowDown(
$event,
slot.item,
readOnly ||
!$hasPermission(
'database.table.move_row',
table,
database.workspace.id
)
)
"
@mousemove="rowMoveOver($event, slot.item)"
@mouseenter="rowMoveOver($event, slot.item)"
></RowCard>
</div>
</div>
<RowCreateModal
v-if="
!readOnly &&
$hasPermission(
'database.table.create_row',
table,
database.workspace.id
)
"
ref="rowCreateModal"
:database="database"
:table="table"
:view="view"
:primary-is-sortable="true"
:visible-fields="cardFields"
:hidden-fields="hiddenFields"
:show-hidden-fields="showHiddenFieldsInRowModal"
:all-fields-in-table="fields"
@toggle-hidden-fields-visibility="
showHiddenFieldsInRowModal = !showHiddenFieldsInRowModal
"
@created="createRow"
@order-fields="orderFields"
@toggle-field-visibility="toggleFieldVisibility"
@field-updated="$emit('refresh', $event)"
@field-deleted="$emit('refresh')"
></RowCreateModal>
<RowEditModal
ref="rowEditModal"
enable-navigation
:database="database"
:table="table"
:view="view"
:all-fields-in-table="fields"
:primary-is-sortable="true"
:visible-fields="cardFields"
:hidden-fields="hiddenFields"
:rows="allRows"
:read-only="
readOnly ||
!$hasPermission(
'database.table.update_row',
table,
database.workspace.id
)
"
:show-hidden-fields="showHiddenFieldsInRowModal"
@hidden="$emit('selected-row', undefined)"
@toggle-hidden-fields-visibility="
showHiddenFieldsInRowModal = !showHiddenFieldsInRowModal
"
@update="updateValue"
@order-fields="orderFields"
@toggle-field-visibility="toggleFieldVisibility"
@field-updated="$emit('refresh', $event)"
@field-deleted="$emit('refresh')"
@field-created="showFieldCreated"
@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 debounce from 'lodash/debounce'
import { mapGetters } from 'vuex'
import ResizeObserver from 'resize-observer-polyfill'
import { notifyIf } from '@baserow/modules/core/utils/error'
import { getCardHeight } from '@baserow/modules/database/utils/card'
import {
recycleSlots,
orderSlots,
} from '@baserow/modules/database/utils/virtualScrolling'
import {
sortFieldsByOrderAndIdFunction,
filterVisibleFieldsFunction,
filterHiddenFieldsFunction,
} from '@baserow/modules/database/utils/view'
import RowCard from '@baserow/modules/database/components/card/RowCard'
import RowCreateModal from '@baserow/modules/database/components/row/RowCreateModal'
import RowEditModal from '@baserow/modules/database/components/row/RowEditModal'
import bufferedRowsDragAndDrop from '@baserow/modules/database/mixins/bufferedRowsDragAndDrop'
import viewHelpers from '@baserow/modules/database/mixins/viewHelpers'
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'
export default {
name: 'GalleryView',
components: { RowCard, RowCreateModal, RowEditModal },
mixins: [viewHelpers, bufferedRowsDragAndDrop, viewDecoration],
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,
},
storePrefix: {
type: String,
required: true,
},
},
data() {
return {
gutterSize: 30,
minimumCardWidth: 280,
height: 0,
cardWidth: 0,
buffer: [],
showHiddenFieldsInRowModal: false,
dragAndDropCloneClass: 'gallery-view__card--dragging-clone',
}
},
computed: {
...mapGetters({
row: 'rowModalNavigation/getRow',
}),
firstRows() {
return this.allRows.slice(0, 200)
},
/**
* In order for the virtual scrolling to work, we need to know what the height of
* the card is to correctly position it.
*/
cardHeight() {
return getCardHeight(
this.cardFields,
this.coverImageField,
this.$registry
)
},
/**
* Returns the visible field objects in the right order.
*/
cardFields() {
const fieldOptions = this.fieldOptions
return this.fields
.filter(filterVisibleFieldsFunction(fieldOptions))
.sort(sortFieldsByOrderAndIdFunction(fieldOptions))
},
hiddenFields() {
const fieldOptions = this.fieldOptions
return this.fields
.filter(filterHiddenFieldsFunction(fieldOptions))
.sort(sortFieldsByOrderAndIdFunction(fieldOptions))
},
coverImageField() {
const fieldId = this.view.card_cover_image_field
return this.fields.find((field) => field.id === fieldId) || null
},
activeSearchTerm() {
return this.$store.getters[
`${this.storePrefix}view/gallery/getActiveSearchTerm`
]
},
},
watch: {
cardHeight() {
this.$nextTick(() => {
this.updateBuffer(true, false)
})
},
allRows() {
this.$nextTick(() => {
this.updateBuffer(true, false)
})
},
row: {
deep: true,
handler(row, oldRow) {
if (this.$refs.rowEditModal) {
if (
(oldRow === null && row !== null) ||
(oldRow && row && oldRow.id !== row.id)
) {
this.populateAndEditRow(row)
} else if (oldRow !== null && row === 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)
}
}
},
},
},
mounted() {
this.updateBuffer()
this.$el.resizeObserver = new ResizeObserver(() => {
this.updateBuffer()
})
this.$el.resizeObserver.observe(this.$el)
const fireUpdateBuffer = {
last: Date.now(),
distance: 0,
}
// Debounce function that's called when the user scrolls really fast. This is to
// make sure that the `updateBuffer` method is called with the
// `dispatchVisibleRows` parameter to true when the user immediately stops
// scrolling fast.
const updateBufferDebounced = debounce(() => {
this.updateBuffer(true, false)
}, 100)
// This debounced function is called when the user stops scrolling.
const updateOrderDebounced = debounce(() => {
this.updateBuffer(false, true)
}, 110)
this.$el.scrollEvent = (event) => {
// Call the update order debounce function to simulate a stop scrolling event.
updateOrderDebounced()
const now = Date.now()
const { scrollTop } = event.target
const distance = Math.abs(scrollTop - fireUpdateBuffer.distance)
const timeDelta = now - fireUpdateBuffer.last
if (timeDelta > 100) {
const velocity = distance / timeDelta
fireUpdateBuffer.last = now
fireUpdateBuffer.distance = scrollTop
if (velocity < 2.5) {
// When scrolling "slow", the dispatchVisibleRows parameter is true so that
// the visible rows are fetched if needed.
updateBufferDebounced.cancel()
this.updateBuffer(true, false)
} else {
// Check if the user is scrolling super fast because in that case we don't
// fetch the rows when they're not needed.
updateBufferDebounced()
this.updateBuffer(false, false)
}
} else {
// If scroll stopped within the 100ms we still want to have a last
// updateBuffer(true) call.
updateBufferDebounced()
this.updateBuffer(false, false)
}
}
this.$refs.scroll.addEventListener('scroll', this.$el.scrollEvent)
if (this.row !== null) {
this.populateAndEditRow(this.row)
}
},
beforeDestroy() {
this.$el.resizeObserver.unobserve(this.$el)
this.$refs.scroll.removeEventListener('scroll', this.$el.scrollEvent)
},
beforeCreate() {
this.$options.computed = {
...(this.$options.computed || {}),
...mapGetters({
allRows: this.$options.propsData.storePrefix + 'view/gallery/getRows',
fieldOptions:
this.$options.propsData.storePrefix +
'view/gallery/getAllFieldOptions',
}),
}
},
methods: {
getDragAndDropStoreName(props) {
return `${props.storePrefix}view/gallery`
},
/**
* This method makes sure that the correct cards/rows are shown based on the
* scroll offset, viewport width, viewport height and card height. Based on these
* values we can calculate which how many rows should be visible, which ones are
* visible and what their position is without rendering all the rows in the store
* at once.
*
* @param dispatchVisibleRows Indicates whether we want to dispatch the visibleRows
* action in the store. In some cases, when scrolling really fast through data we
* might want to wait a small moment before calling the action, which will make a
* request to the backend if needed.
*/
updateBuffer(dispatchVisibleRows = true, updateOrder = true) {
const el = this.$refs.scroll
const gutterSize = this.gutterSize
const containerWidth = el.clientWidth
const containerHeight = el.clientHeight
const cardsPerRow = Math.min(
Math.max(Math.floor(containerWidth / this.minimumCardWidth), 1),
20
)
const cardHeight = this.cardHeight
const cardWidth = (containerWidth - gutterSize) / cardsPerRow - gutterSize
const totalRows = Math.ceil(this.allRows.length / cardsPerRow)
const height = totalRows * (cardHeight + gutterSize) + gutterSize
this.cardWidth = cardWidth
this.height = height
const scrollTop = el.scrollTop
const minimumCardsToRender =
(Math.ceil(containerHeight / (cardHeight + gutterSize)) + 1) *
cardsPerRow
const startIndex =
Math.floor(scrollTop / (cardHeight + gutterSize)) * cardsPerRow
const endIndex = startIndex + minimumCardsToRender
const visibleRows = this.allRows.slice(startIndex, endIndex)
const getPosition = (row, positionInVisible) => {
const positionInAll = startIndex + positionInVisible
return {
left:
gutterSize +
(positionInAll % cardsPerRow) * (gutterSize + cardWidth),
top:
gutterSize +
Math.floor(positionInAll / cardsPerRow) * (gutterSize + cardHeight),
}
}
recycleSlots(this.buffer, visibleRows, getPosition, minimumCardsToRender)
if (updateOrder) {
orderSlots(this.buffer, visibleRows)
}
if (dispatchVisibleRows) {
// Tell the store which rows/cards are visible so that it can fetch the missing
// ones if needed.
this.$store.dispatch(
this.storePrefix + 'view/gallery/fetchMissingRowsInNewRange',
{
startIndex,
endIndex,
}
)
}
},
async createRow({ row, callback }) {
try {
await this.$store.dispatch(
this.storePrefix + 'view/gallery/createNewRow',
{
view: this.view,
table: this.table,
fields: this.fields,
values: row,
}
)
callback()
} catch (error) {
callback(error)
}
},
async updateValue({ field, row, value, oldValue }) {
try {
await this.$store.dispatch(
this.storePrefix + 'view/gallery/updateRowValue',
{
table: this.table,
view: this.view,
fields: this.fields,
row,
field,
value,
oldValue,
}
)
} catch (error) {
notifyIf(error, 'field')
}
},
/**
* Is called when the user clicks on the card but did not move it to another
* position.
*/
rowClick(row) {
this.$refs.rowEditModal.show(row.id)
this.$emit('selected-row', row)
},
/**
* 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/gallery/refreshRowFromBackend',
{
table: this.table,
row,
}
)
} catch (error) {
notifyIf(error, 'row')
}
},
/**
* Calls the fieldCreated callback and shows the hidden fields section
* because new fields are hidden by default.
*/
showFieldCreated({ fetchNeeded, ...context }) {
this.fieldCreated({ fetchNeeded, ...context })
this.showHiddenFieldsInRowModal = true
},
/**
* 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)
},
},
}
</script>