mirror of
https://gitlab.com/bramw/baserow.git
synced 2024-11-25 00:46:46 +00:00
557 lines
17 KiB
Vue
557 lines
17 KiB
Vue
<template>
|
|
<div
|
|
v-auto-scroll="{
|
|
enabled: () => isMultiSelectHolding,
|
|
orientation: 'horizontal',
|
|
speed: 4,
|
|
padding: 10,
|
|
onScroll: (speed) => {
|
|
$emit('scroll', { pixelY: 0, pixelX: speed })
|
|
return false
|
|
},
|
|
}"
|
|
>
|
|
<div
|
|
v-for="({ left }, index) in groupByDividers"
|
|
:key="'group-by-divider-' + index"
|
|
class="grid-view__group-by-divider"
|
|
:style="{ left: left + 'px' }"
|
|
></div>
|
|
<HorizontalResize
|
|
v-for="({ groupBy, left }, index) in groupByDividers"
|
|
:key="'group-by-width-' + index"
|
|
class="grid-view__head-group-width-handle"
|
|
:style="{ left: left + 'px' }"
|
|
:width="groupBy.width"
|
|
:min="100"
|
|
@move="moveGroupWidth(groupBy, view, $event)"
|
|
@update="updateGroupWidth(groupBy, view, database, readOnly, $event)"
|
|
></HorizontalResize>
|
|
<div class="grid-view__inner" :style="{ 'min-width': width + 'px' }">
|
|
<GridViewHead
|
|
:database="database"
|
|
:table="table"
|
|
:view="view"
|
|
:all-fields-in-table="allFieldsInTable"
|
|
:visible-fields="visibleFields"
|
|
:include-field-width-handles="includeFieldWidthHandles"
|
|
:include-row-details="includeRowDetails"
|
|
:include-add-field="includeAddField"
|
|
:include-grid-view-identifier-dropdown="
|
|
includeGridViewIdentifierDropdown
|
|
"
|
|
:include-group-by="includeGroupBy"
|
|
:read-only="readOnly"
|
|
:store-prefix="storePrefix"
|
|
@field-created="$emit('field-created', $event)"
|
|
@refresh="$emit('refresh', $event)"
|
|
@dragging="
|
|
canOrderFields &&
|
|
!$event.field.primary &&
|
|
$refs.fieldDragging.start($event.field, $event.event)
|
|
"
|
|
></GridViewHead>
|
|
<div
|
|
ref="body"
|
|
v-auto-scroll="{
|
|
enabled: () => isMultiSelectHolding,
|
|
speed: 4,
|
|
padding: 10,
|
|
onScroll: (speed) => {
|
|
$emit('scroll', { pixelY: speed, pixelX: 0 })
|
|
return false
|
|
},
|
|
}"
|
|
class="grid-view__body"
|
|
>
|
|
<div class="grid-view__body-inner">
|
|
<GridViewPlaceholder
|
|
:visible-fields="visibleFields"
|
|
:view="view"
|
|
:include-row-details="includeRowDetails"
|
|
:include-group-by="includeGroupBy"
|
|
:store-prefix="storePrefix"
|
|
></GridViewPlaceholder>
|
|
<GridViewGroups
|
|
v-if="includeGroupBy && activeGroupBys.length > 0"
|
|
:all-fields-in-table="allFieldsInTable"
|
|
:group-by-value-sets="groupByValueSets"
|
|
:store-prefix="storePrefix"
|
|
></GridViewGroups>
|
|
<GridViewRows
|
|
v-if="includeRowDetails || visibleFields.length > 0"
|
|
ref="rows"
|
|
:view="view"
|
|
:rendered-fields="fieldsToRender"
|
|
:visible-fields="visibleFields"
|
|
:all-fields-in-table="allFieldsInTable"
|
|
:workspace-id="database.workspace.id"
|
|
:decorations-by-place="decorationsByPlace"
|
|
:left-offset="fieldsLeftOffset"
|
|
:primary-field-is-sticky="primaryFieldIsSticky"
|
|
:include-row-details="includeRowDetails"
|
|
:include-group-by="includeGroupBy"
|
|
:rows-at-end-of-groups="rowsAtEndOfGroups"
|
|
:read-only="readOnly"
|
|
:store-prefix="storePrefix"
|
|
v-on="$listeners"
|
|
></GridViewRows>
|
|
<GridViewRowAdd
|
|
v-if="
|
|
!readOnly &&
|
|
!table.data_sync &&
|
|
(includeRowDetails || visibleFields.length > 0) &&
|
|
$hasPermission(
|
|
'database.table.create_row',
|
|
table,
|
|
database.workspace.id
|
|
)
|
|
"
|
|
:visible-fields="visibleFields"
|
|
:include-row-details="includeRowDetails"
|
|
:store-prefix="storePrefix"
|
|
v-on="$listeners"
|
|
></GridViewRowAdd>
|
|
<div v-else class="grid-view__row-placeholder"></div>
|
|
</div>
|
|
</div>
|
|
<div class="grid-view__foot">
|
|
<div v-if="includeRowDetails" class="grid-view__foot-info">
|
|
{{ $tc('gridView.rowCount', count, { count }) }}
|
|
</div>
|
|
<div
|
|
v-for="field in visibleFields"
|
|
:key="field.id"
|
|
:style="{ width: getFieldWidth(field) + 'px' }"
|
|
>
|
|
<GridViewFieldFooter
|
|
:database="database"
|
|
:field="field"
|
|
:view="view"
|
|
:store-prefix="storePrefix"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<GridViewFieldDragging
|
|
ref="fieldDragging"
|
|
:view="view"
|
|
:fields="draggingFields"
|
|
:offset="draggingOffset"
|
|
:container-width="width"
|
|
:read-only="readOnly"
|
|
:store-prefix="storePrefix"
|
|
@scroll="$emit('scroll', $event)"
|
|
></GridViewFieldDragging>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import { mapGetters } from 'vuex'
|
|
import debounce from 'lodash/debounce'
|
|
import ResizeObserver from 'resize-observer-polyfill'
|
|
|
|
import GridViewHead from '@baserow/modules/database/components/view/grid/GridViewHead'
|
|
import GridViewPlaceholder from '@baserow/modules/database/components/view/grid/GridViewPlaceholder'
|
|
import GridViewGroups from '@baserow/modules/database/components/view/grid/GridViewGroups'
|
|
import GridViewRows from '@baserow/modules/database/components/view/grid/GridViewRows'
|
|
import GridViewRowAdd from '@baserow/modules/database/components/view/grid/GridViewRowAdd'
|
|
import GridViewFieldDragging from '@baserow/modules/database/components/view/grid/GridViewFieldDragging'
|
|
import gridViewHelpers from '@baserow/modules/database/mixins/gridViewHelpers'
|
|
import GridViewFieldFooter from '@baserow/modules/database/components/view/grid/GridViewFieldFooter'
|
|
import HorizontalResize from '@baserow/modules/core/components/HorizontalResize'
|
|
import { fieldValuesAreEqualInObjects } from '@baserow/modules/database/utils/groupBy'
|
|
|
|
export default {
|
|
name: 'GridViewSection',
|
|
components: {
|
|
HorizontalResize,
|
|
GridViewHead,
|
|
GridViewPlaceholder,
|
|
GridViewGroups,
|
|
GridViewRows,
|
|
GridViewRowAdd,
|
|
GridViewFieldDragging,
|
|
GridViewFieldFooter,
|
|
},
|
|
mixins: [gridViewHelpers],
|
|
props: {
|
|
visibleFields: {
|
|
type: Array,
|
|
required: true,
|
|
},
|
|
allFieldsInTable: {
|
|
type: Array,
|
|
required: true,
|
|
},
|
|
decorationsByPlace: {
|
|
type: Object,
|
|
required: true,
|
|
},
|
|
database: {
|
|
type: Object,
|
|
required: true,
|
|
},
|
|
table: {
|
|
type: Object,
|
|
required: true,
|
|
},
|
|
view: {
|
|
type: Object,
|
|
required: true,
|
|
},
|
|
includeFieldWidthHandles: {
|
|
type: Boolean,
|
|
required: false,
|
|
default: () => true,
|
|
},
|
|
includeRowDetails: {
|
|
type: Boolean,
|
|
required: false,
|
|
default: () => false,
|
|
},
|
|
includeGroupBy: {
|
|
type: Boolean,
|
|
required: false,
|
|
default: () => false,
|
|
},
|
|
includeAddField: {
|
|
type: Boolean,
|
|
required: false,
|
|
default: () => false,
|
|
},
|
|
includeGridViewIdentifierDropdown: {
|
|
type: Boolean,
|
|
required: false,
|
|
default: () => false,
|
|
},
|
|
canOrderFields: {
|
|
type: Boolean,
|
|
required: false,
|
|
default: () => false,
|
|
},
|
|
primaryFieldIsSticky: {
|
|
type: Boolean,
|
|
required: false,
|
|
default: () => true,
|
|
},
|
|
readOnly: {
|
|
type: Boolean,
|
|
required: true,
|
|
},
|
|
},
|
|
data() {
|
|
return {
|
|
// Render the first 20 fields by default so that there's at least some data when
|
|
// the page is server side rendered.
|
|
fieldsToRender: this.visibleFields.slice(0, 20),
|
|
// Indicates the offset
|
|
fieldsLeftOffset: 0,
|
|
}
|
|
},
|
|
computed: {
|
|
/**
|
|
* Calculates the total width of the whole section based on the fields and the
|
|
* given options.
|
|
*/
|
|
width() {
|
|
let width = Object.values(this.visibleFields).reduce(
|
|
(value, field) => this.getFieldWidth(field) + value,
|
|
0
|
|
)
|
|
|
|
if (this.includeRowDetails) {
|
|
width += this.gridViewRowDetailsWidth
|
|
}
|
|
|
|
// The add button has a width of 100 and we reserve 100 at the right side.
|
|
if (this.includeAddField) {
|
|
width += 100 + 100
|
|
}
|
|
|
|
return width
|
|
},
|
|
draggingFields() {
|
|
return this.visibleFields.filter((f) => !f.primary)
|
|
},
|
|
draggingOffset() {
|
|
let offset = this.visibleFields
|
|
.filter((f) => f.primary)
|
|
.reduce((sum, f) => sum + this.getFieldWidth(f), 0)
|
|
|
|
if (this.includeRowDetails) {
|
|
offset += this.gridViewRowDetailsWidth
|
|
}
|
|
|
|
return offset
|
|
},
|
|
groupByDividers() {
|
|
if (!this.includeGroupBy) {
|
|
return []
|
|
}
|
|
|
|
let last = 0
|
|
const dividers = this.activeGroupBys
|
|
.filter((groupBy, index) => index < this.activeGroupBys.length - 1)
|
|
.map((groupBy) => {
|
|
last += groupBy.width
|
|
return { groupBy, left: last }
|
|
})
|
|
|
|
return dividers
|
|
},
|
|
/**
|
|
* Computes an object that can be used by the `GridViewGroups` and `GridViewRows`
|
|
* components to correctly visualize the groups. Even though both components need
|
|
* different data, we're computing it in the same function because having only one
|
|
* loop is more efficient.
|
|
*
|
|
* groupBySets:
|
|
*
|
|
* Every entry in the array represents a group, and contains a list of spans, which
|
|
* are essentially a row span of the rows in that group.
|
|
*
|
|
* [
|
|
* {
|
|
* "groupBy": object,
|
|
* "groupSpans": [
|
|
* {
|
|
* "rowSpan": 10,
|
|
* "value": any,
|
|
* },
|
|
* ...
|
|
* ]
|
|
* },
|
|
* ...
|
|
* ]
|
|
*
|
|
* rowsAtEndOfGroups:
|
|
*
|
|
* Indicates whether the row is the start or end of the last group. This is needed
|
|
* to add a visual divider
|
|
*
|
|
* [1, 2]
|
|
*
|
|
*/
|
|
groupBySetsAndRowsAtEndOfGroups() {
|
|
const groupBys = this.activeGroupBys
|
|
const metadata = this.groupByMetadata
|
|
const rows = this.allRows
|
|
const rowsAtEndOfGroups = new Set()
|
|
|
|
const groupBySets = groupBys.map((groupBy, groupByIndex) => {
|
|
const groupSpans = []
|
|
let lastGroup = null
|
|
|
|
rows.forEach((row, index) => {
|
|
const previousRow = rows[index - 1]
|
|
const nextRow = rows[index + 1]
|
|
|
|
/**
|
|
* Helper function that checks whether the value is the same for both rows in
|
|
* this group, but also the previous ones. This is needed because we need to
|
|
* start a new group if the previous value doesn't match.
|
|
*/
|
|
const checkIfInSameGroup = (row1, row2) => {
|
|
if (row1 === undefined || row2 === undefined) {
|
|
return false
|
|
}
|
|
return groupBys.slice(0, groupByIndex + 1).every((groupBy) => {
|
|
const groupByField = this.allFieldsInTable.find(
|
|
(f) => f.id === groupBy.field
|
|
)
|
|
const groupByFieldType = this.$registry.get(
|
|
'field',
|
|
groupByField.type
|
|
)
|
|
return groupByFieldType.isEqual(
|
|
groupByField,
|
|
row1[`field_${groupBy.field}`],
|
|
row2[`field_${groupBy.field}`]
|
|
)
|
|
})
|
|
}
|
|
|
|
if (!checkIfInSameGroup(previousRow, row)) {
|
|
// The group by metadata is a dict where the key is equal to the group by,
|
|
// and the value an array containing the count for each unique value
|
|
// combination. Below we're looking through the entries to find the
|
|
// matching count for the row values.
|
|
const count =
|
|
(metadata[`field_${groupBy.field}`] || []).find((entry) => {
|
|
const groupByFields = groupBys
|
|
.slice(0, groupByIndex + 1)
|
|
.map((groupBy) => {
|
|
return this.allFieldsInTable.find(
|
|
(f) => f.id === groupBy.field
|
|
)
|
|
})
|
|
return fieldValuesAreEqualInObjects(
|
|
groupByFields,
|
|
this.$registry,
|
|
entry,
|
|
row,
|
|
true
|
|
)
|
|
})?.count || -1
|
|
|
|
// If the start of a group, then create a new span object in the last.
|
|
lastGroup = {
|
|
rowSpan: 1,
|
|
value: row[`field_${groupBy.field}`],
|
|
count,
|
|
}
|
|
} else {
|
|
// If the value hasn't changed, it means that this row falls within the
|
|
// already started group, to we have to increase the row span.
|
|
lastGroup.rowSpan += 1
|
|
}
|
|
|
|
if (!checkIfInSameGroup(row, nextRow)) {
|
|
// If the group ends, it must be added to the array.
|
|
groupSpans.push(lastGroup)
|
|
lastGroup = null
|
|
|
|
// If we're at the last group, we want to store whether the row is last so
|
|
// that we can visually show divider. This is only needed for the last group
|
|
// because that's where the divider must match the one with the group.
|
|
if (groupByIndex === groupBys.length - 1) {
|
|
rowsAtEndOfGroups.add(row.id)
|
|
}
|
|
}
|
|
})
|
|
|
|
return { groupBy, groupSpans }
|
|
})
|
|
|
|
return { groupBySets, rowsAtEndOfGroups }
|
|
},
|
|
groupByValueSets() {
|
|
return this.groupBySetsAndRowsAtEndOfGroups.groupBySets
|
|
},
|
|
rowsAtEndOfGroups() {
|
|
return this.groupBySetsAndRowsAtEndOfGroups.rowsAtEndOfGroups
|
|
},
|
|
},
|
|
watch: {
|
|
fieldOptions: {
|
|
deep: true,
|
|
handler() {
|
|
this.updateVisibleFieldsInRow()
|
|
},
|
|
},
|
|
visibleFields: {
|
|
deep: true,
|
|
handler() {
|
|
this.updateVisibleFieldsInRow()
|
|
},
|
|
},
|
|
},
|
|
beforeCreate() {
|
|
this.$options.computed = {
|
|
...(this.$options.computed || {}),
|
|
...mapGetters({
|
|
isMultiSelectHolding:
|
|
this.$options.propsData.storePrefix +
|
|
'view/grid/isMultiSelectHolding',
|
|
count: this.$options.propsData.storePrefix + 'view/grid/getCount',
|
|
allRows: this.$options.propsData.storePrefix + 'view/grid/getAllRows',
|
|
groupByMetadata:
|
|
this.$options.propsData.storePrefix + 'view/grid/getGroupByMetadata',
|
|
}),
|
|
}
|
|
},
|
|
mounted() {
|
|
// When the component first loads, we need to check
|
|
this.updateVisibleFieldsInRow()
|
|
|
|
const updateDebounced = debounce(() => {
|
|
this.updateVisibleFieldsInRow()
|
|
}, 50)
|
|
|
|
// When the viewport resizes, we need to check if there are fields that must be
|
|
// rendered.
|
|
this.$el.resizeObserver = new ResizeObserver(() => {
|
|
updateDebounced()
|
|
})
|
|
this.$el.resizeObserver.observe(this.$el)
|
|
|
|
// When the user scrolls horizontally, we need to check if there fields/cells that
|
|
// have moved into the viewport and must be rendered.
|
|
const fireUpdateBuffer = {
|
|
last: Date.now(),
|
|
distance: 0,
|
|
}
|
|
this.$el.horizontalScrollEvent = (event) => {
|
|
// Call the update order debounce function to simulate a stop scrolling event.
|
|
updateDebounced()
|
|
|
|
const now = Date.now()
|
|
const { scrollLeft } = event.target
|
|
|
|
const distance = Math.abs(scrollLeft - fireUpdateBuffer.distance)
|
|
const timeDelta = now - fireUpdateBuffer.last
|
|
|
|
if (timeDelta > 100) {
|
|
const velocity = distance / timeDelta
|
|
|
|
fireUpdateBuffer.last = now
|
|
fireUpdateBuffer.distance = scrollLeft
|
|
|
|
if (velocity < 2.5) {
|
|
updateDebounced.cancel()
|
|
this.updateVisibleFieldsInRow()
|
|
}
|
|
}
|
|
}
|
|
this.$el.addEventListener('scroll', this.$el.horizontalScrollEvent)
|
|
},
|
|
beforeDestroy() {
|
|
this.$el.resizeObserver.unobserve(this.$el)
|
|
this.$el.removeEventListener('scroll', this.$el.horizontalScrollEvent)
|
|
},
|
|
methods: {
|
|
/**
|
|
* For performance reasons we only want to render the cells are visible in the
|
|
* viewport. This method makes sure that the right cells/fields are visible. It's
|
|
* for example called when the user scrolls, when the window is resized or when a
|
|
* field changes.
|
|
*/
|
|
updateVisibleFieldsInRow() {
|
|
const width = this.$el.clientWidth
|
|
const scrollLeft = this.$el.scrollLeft
|
|
// The padding is added to the start and end of the viewport to make sure that
|
|
// cells nearby will always be ready to be displayed.
|
|
const padding = 200
|
|
const viewportStart = scrollLeft - padding
|
|
const viewportEnd = scrollLeft + width + padding
|
|
let leftOffset = null
|
|
let left = 0
|
|
|
|
// Create an array containing the fields that are currently visible in the
|
|
// viewport and must be rendered.
|
|
const fieldsToRender = this.visibleFields.filter((field) => {
|
|
const width = this.getFieldWidth(field)
|
|
const right = left + width
|
|
const visible = right >= viewportStart && left <= viewportEnd
|
|
if (visible && leftOffset === null) {
|
|
leftOffset = left
|
|
}
|
|
left = right
|
|
return visible
|
|
})
|
|
|
|
if (
|
|
JSON.stringify(this.fieldsToRender) !== JSON.stringify(fieldsToRender)
|
|
) {
|
|
this.fieldsToRender = fieldsToRender
|
|
}
|
|
|
|
if (leftOffset !== this.fieldsLeftOffset) {
|
|
this.fieldsLeftOffset = leftOffset
|
|
}
|
|
},
|
|
},
|
|
}
|
|
</script>
|