import Vue from 'vue' import axios from 'axios' import _ from 'lodash' import BigNumber from 'bignumber.js' import { uuid } from '@baserow/modules/core/utils/string' import { clone } from '@baserow/modules/core/utils/object' import { GroupTaskQueue } from '@baserow/modules/core/utils/queue' import ViewService from '@baserow/modules/database/services/view' import GridService from '@baserow/modules/database/services/view/grid' import RowService from '@baserow/modules/database/services/row' import { calculateSingleRowSearchMatches, extractRowMetadata, getRowSortFunction, matchSearchFilters, getFilters, getGroupBy, getOrderBy, } from '@baserow/modules/database/utils/view' import { RefreshCancelledError } from '@baserow/modules/core/errors' import { prepareRowForRequest, prepareNewOldAndUpdateRequestValues, extractRowReadOnlyValues, } from '@baserow/modules/database/utils/row' import { getDefaultSearchModeFromEnv } from '@baserow/modules/database/utils/search' import { fieldValuesAreEqualInObjects } from '@baserow/modules/database/utils/groupBy' const ORDER_STEP = '1' const ORDER_STEP_BEFORE = '0.00000000000000000001' export function populateRow(row, metadata = {}) { row._ = { metadata, persistentId: uuid(), loading: false, hover: false, selectedBy: [], matchFilters: true, matchSortings: true, // Whether the row should be displayed based on the current activeSearchTerm term. matchSearch: true, // Contains the specific field ids which match the activeSearchTerm term. // Could be empty even when matchSearch is true when there is no // activeSearchTerm term applied. fieldSearchMatches: [], // Keeping the selected state with the row has the best performance when navigating // between cells. selected: false, selectedFieldId: -1, } return row } const updatePositionFn = { previous: (rowIndex, fieldIndex) => { return [rowIndex, fieldIndex - 1] }, next: (rowIndex, fieldIndex) => { return [rowIndex, fieldIndex + 1] }, above: (rowIndex, fieldIndex) => { return [rowIndex - 1, fieldIndex] }, below: (rowIndex, fieldIndex) => { return [rowIndex + 1, fieldIndex] }, } function getPendingOperationKey(fieldId, rowId) { return `${fieldId}-${rowId}` } export const state = () => ({ // Indicates if multiple cell selection is active 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. */ multiSelectHeadRowIndex: -1, multiSelectHeadFieldIndex: -1, multiSelectTailRowIndex: -1, multiSelectTailFieldIndex: -1, // Keep the original row and field index to remember where the selection began multiSelectStartRowIndex: -1, multiSelectStartFieldIndex: -1, // The last used grid id. lastGridId: -1, // If true, ad hoc filtering is used instead of persistent one adhocFiltering: false, // If true, ad hoc sorting is used adhocSorting: false, // Contains the custom field options per view. Things like the field width are // stored here. fieldOptions: {}, // Contains the buffered rows that we keep in memory. Depending on the // scrollOffset rows will be added or removed from this buffer. Most of the times, // it will contain 3 times the bufferRequestSize in rows. rows: [], // The total amount of rows in the table. count: 0, // The height of a single row. rowHeight: 33, // The distance to the top in pixels the visible rows should have. rowsTop: 0, // The amount of rows that must be visible above and under the middle row. rowPadding: 16, // The amount of rows that will be requested per request. bufferRequestSize: 40, // The start index of the buffer in the whole table. bufferStartIndex: 0, // The limit of the buffer measured from the start index in the whole table. bufferLimit: 0, // The start index of the visible rows of the rows in the buffer. rowsStartIndex: 0, // The end index of the visible rows of the rows buffer. rowsEndIndex: 0, // The last scrollTop when the visibleByScrollTop was called. scrollTop: 0, // The height of the window where the rows are displayed in. windowHeight: 0, // Indicates if the user is hovering over the add row button. addRowHover: false, // A user provided optional search term which can be used to filter down rows. activeSearchTerm: '', // If true then the activeSearchTerm will be sent to the server to filter rows // entirely out. When false no server filter will be applied and rows which do not // have any matching cells will still be displayed. hideRowsNotMatchingSearch: true, fieldAggregationData: {}, activeGroupBys: [], groupByMetadata: {}, // Contains a fieldId and rowId string pair that looks like `{fieldId}-{rowId}`. If // in the array, then that cell is a loading state. This is for example used for // fields that use a background worker to compute the value like the AI field. pendingFieldOps: {}, }) export const mutations = { CLEAR_ROWS(state) { state.fieldOptions = {} state.count = 0 state.rows = [] state.rowsTop = 0 state.bufferStartIndex = 0 state.bufferLimit = 0 state.rowsStartIndex = 0 state.rowsEndIndex = 0 state.scrollTop = 0 state.addRowHover = false state.activeSearchTerm = '' state.hideRowsNotMatchingSearch = true state.pendingFieldOps = {} }, SET_ACTIVE_GROUP_BYS(state, groupBys) { state.activeGroupBys = groupBys }, SET_SEARCH(state, { activeSearchTerm, hideRowsNotMatchingSearch }) { state.activeSearchTerm = activeSearchTerm.trim() state.hideRowsNotMatchingSearch = hideRowsNotMatchingSearch }, SET_LAST_GRID_ID(state, gridId) { state.lastGridId = gridId }, SET_ADHOC_FILTERING(state, adhocFiltering) { state.adhocFiltering = adhocFiltering }, SET_ADHOC_SORTING(state, adhocSorting) { state.adhocSorting = adhocSorting }, SET_SCROLL_TOP(state, scrollTop) { state.scrollTop = scrollTop }, SET_WINDOW_HEIGHT(state, value) { state.windowHeight = value }, SET_ROW_PADDING(state, value) { state.rowPadding = value }, SET_BUFFER_START_INDEX(state, value) { state.bufferStartIndex = value }, SET_BUFFER_LIMIT(state, value) { state.bufferLimit = value }, SET_COUNT(state, value) { state.count = value }, SET_ROWS_INDEX(state, { startIndex, endIndex, top }) { state.rowsStartIndex = startIndex state.rowsEndIndex = endIndex state.rowsTop = top }, SET_ADD_ROW_HOVER(state, value) { state.addRowHover = value }, /** * It will add and remove rows to the state based on the provided values. For example * if prependToRows is a positive number that amount of the provided rows will be * added to the state. If that number is negative that amount will be removed from * the state. Same goes for the appendToRows, only then it will be appended. */ ADD_ROWS( state, { rows, prependToRows, appendToRows, count, bufferStartIndex, bufferLimit } ) { state.count = count state.bufferStartIndex = bufferStartIndex state.bufferLimit = bufferLimit if (prependToRows > 0) { state.rows = [...rows.slice(0, prependToRows), ...state.rows] } if (appendToRows > 0) { state.rows.push(...rows.slice(0, appendToRows)) } if (prependToRows < 0) { state.rows = state.rows.splice(Math.abs(prependToRows)) } if (appendToRows < 0) { state.rows = state.rows.splice( 0, state.rows.length - Math.abs(appendToRows) ) } }, REPLACE_ALL_FIELD_OPTIONS(state, fieldOptions) { state.fieldOptions = fieldOptions }, UPDATE_ALL_FIELD_OPTIONS(state, fieldOptions) { state.fieldOptions = _.merge({}, state.fieldOptions, fieldOptions) }, /** * Only adds the new field options and removes the deleted ones. * Existing field options will be modified only if they are important * for public view sharing. */ REPLACE_PUBLIC_FIELD_OPTIONS(state, fieldOptions) { // Add the missing field options or modify existing ones Object.keys(fieldOptions).forEach((key) => { const exists = Object.prototype.hasOwnProperty.call( state.fieldOptions, key ) if (exists) { const propsToUpdate = ['aggregation_raw_type', 'aggregation_type'] const singleFieldOptions = state.fieldOptions[key] Object.keys(singleFieldOptions).forEach((optionKey) => { if (propsToUpdate.includes(optionKey)) { state.fieldOptions[key][optionKey] = fieldOptions[key][optionKey] } }) } else { Vue.set(state.fieldOptions, key, fieldOptions[key]) } }) // Remove the deleted ones. Object.keys(state.fieldOptions).forEach((key) => { const exists = Object.prototype.hasOwnProperty.call(fieldOptions, key) if (!exists) { Vue.delete(state.fieldOptions, key) } }) }, UPDATE_FIELD_OPTIONS_OF_FIELD(state, { fieldId, values }) { if (Object.prototype.hasOwnProperty.call(state.fieldOptions, fieldId)) { Object.assign(state.fieldOptions[fieldId], values) } else { state.fieldOptions = Object.assign({}, state.fieldOptions, { [fieldId]: values, }) } }, DELETE_FIELD_OPTIONS(state, fieldId) { if (Object.prototype.hasOwnProperty.call(state.fieldOptions, fieldId)) { Vue.delete(state.fieldOptions, fieldId) } }, SET_ROW_HOVER(state, { row, value }) { row._.hover = value }, SET_ROW_LOADING(state, { row, value }) { row._.loading = value }, SET_ROW_SEARCH_MATCHES(state, { row, matchSearch, fieldSearchMatches }) { row._.fieldSearchMatches.slice(0).forEach((value) => { if (!fieldSearchMatches.has(value)) { const index = row._.fieldSearchMatches.indexOf(value) row._.fieldSearchMatches.splice(index, 1) } }) fieldSearchMatches.forEach((value) => { if (!row._.fieldSearchMatches.includes(value)) { row._.fieldSearchMatches.push(value) } }) row._.matchSearch = matchSearch }, SET_ROW_MATCH_FILTERS(state, { row, value }) { row._.matchFilters = value }, SET_ROW_MATCH_SORTINGS(state, { row, value }) { row._.matchSortings = value }, ADD_ROW_SELECTED_BY(state, { row, fieldId }) { if (!row._.selectedBy.includes(fieldId)) { row._.selectedBy.push(fieldId) } }, REMOVE_ROW_SELECTED_BY(state, { row, fieldId }) { const index = row._.selectedBy.indexOf(fieldId) if (index > -1) { row._.selectedBy.splice(index, 1) } }, SET_SELECTED_CELL(state, { rowId, fieldId }) { state.rows.forEach((row) => { if (row._.selected) { row._.selected = false row._.selectedFieldId = -1 } if (row.id === rowId) { row._.selected = true row._.selectedFieldId = fieldId } }) }, SET_MULTISELECT_START_ROW_INDEX(state, value) { state.multiSelectStartRowIndex = value }, SET_MULTISELECT_START_FIELD_INDEX(state, value) { state.multiSelectStartFieldIndex = value }, UPDATE_MULTISELECT(state, { position, rowIndex, fieldIndex }) { if (position === 'head') { state.multiSelectHeadRowIndex = rowIndex state.multiSelectHeadFieldIndex = fieldIndex } else if (position === 'tail') { state.multiSelectTailRowIndex = rowIndex state.multiSelectTailFieldIndex = fieldIndex } }, SET_MULTISELECT_HOLDING(state, value) { state.multiSelectHolding = value }, SET_MULTISELECT_ACTIVE(state, value) { state.multiSelectActive = value }, CLEAR_MULTISELECT(state) { state.multiSelectActive = false state.multiSelectHolding = false state.multiSelectHeadRowIndex = -1 state.multiSelectHeadFieldIndex = -1 state.multiSelectTailRowIndex = -1 state.multiSelectTailFieldIndex = -1 }, CLEAR_MULTISELECT_START(state) { state.multiSelectStartRowIndex = -1 state.multiSelectStartFieldIndex = -1 }, ADD_FIELD_TO_ROWS_IN_BUFFER(state, { field, value }) { const name = `field_${field.id}` // We have to replace all the rows by using the map function to make it // reactive and update immediately. If we don't do this, the value in the // field components of the grid and modal don't always have the correct value // binding. state.rows = state.rows.map((row) => { if (!Object.prototype.hasOwnProperty.call(row, name)) { row[`field_${field.id}`] = value } return { ...row } }) }, DECREASE_ORDERS_IN_BUFFER_LOWER_THAN(state, existingOrder) { const min = new BigNumber(existingOrder).integerValue(BigNumber.ROUND_FLOOR) const max = new BigNumber(existingOrder) // Decrease all the orders that have already have been inserted before the same // row. state.rows.forEach((row) => { const order = new BigNumber(row.order) if (order.isGreaterThan(min) && order.isLessThanOrEqualTo(max)) { row.order = order.minus(new BigNumber(ORDER_STEP_BEFORE)).toString() } }) }, INSERT_NEW_ROWS_IN_BUFFER_AT_INDEX(state, { rows, index }) { if (rows.length === 0) { return } const potentialNewBufferLimit = state.bufferLimit + rows.length const maximumBufferLimit = state.bufferRequestSize * 3 state.count += rows.length state.bufferLimit = potentialNewBufferLimit > maximumBufferLimit ? maximumBufferLimit : potentialNewBufferLimit // Insert the new rows state.rows.splice(index, 0, ...rows) // We might have too many rows inserted now state.rows = state.rows.slice(0, state.bufferLimit) }, INSERT_EXISTING_ROW_IN_BUFFER_AT_INDEX(state, { row, index }) { state.rows.splice(index, 0, row) }, MOVE_EXISTING_ROW_IN_BUFFER(state, { row, index }) { const oldIndex = state.rows.findIndex((item) => item.id === row.id) if (oldIndex !== -1) { state.rows.splice(index, 0, state.rows.splice(oldIndex, 1)[0]) } }, UPDATE_ROW_IN_BUFFER(state, { row, values, metadata = false }) { const index = state.rows.findIndex((item) => item.id === row.id) if (index !== -1) { const existingRowState = state.rows[index] Object.assign(existingRowState, values) if (metadata) { existingRowState._.metadata = metadata } } }, UPDATE_ROW_VALUES(state, { row, values }) { Object.assign(row, values) }, UPDATE_ROW_FIELD_VALUE(state, { row, field, value }) { row[`field_${field.id}`] = value }, UPDATE_ROW_METADATA(state, { row, rowMetadataType, updateFunction }) { const currentValue = row._.metadata[rowMetadataType] const newValue = updateFunction(currentValue) if ( !Object.prototype.hasOwnProperty.call(row._.metadata, rowMetadataType) ) { const metaDataCopy = clone(row._.metadata) metaDataCopy[rowMetadataType] = newValue Vue.set(row._, 'metadata', metaDataCopy) } else { Vue.set(row._.metadata, rowMetadataType, newValue) } }, FINALIZE_ROWS_IN_BUFFER(state, { oldRows, newRows, fields }) { const stateRowsCopy = { ...state.rows } for (let i = 0; i < oldRows.length; i++) { const oldRow = oldRows[i] const newRow = newRows[i] const index = state.rows.findIndex((row) => row.id === oldRow.id) if (index === -1) { continue } stateRowsCopy[index].id = newRow.id stateRowsCopy[index].order = new BigNumber(newRow.order) stateRowsCopy[index]._.loading = false Object.keys(newRow).forEach((key) => { if (fields.includes(key)) { stateRowsCopy[index][key] = newRow[key] } }) } this.state.rows = stateRowsCopy }, /** * Deletes a row of which we are sure that it is in the buffer right now. */ DELETE_ROW_IN_BUFFER(state, row) { const index = state.rows.findIndex((item) => item.id === row.id) if (index !== -1) { state.count-- state.bufferLimit-- state.rows.splice(index, 1) } }, /** * Deletes a row from the buffer without updating the buffer limit and count. */ DELETE_ROW_IN_BUFFER_WITHOUT_UPDATE(state, row) { const index = state.rows.findIndex((item) => item.id === row.id) if (index !== -1) { state.rows.splice(index, 1) } }, SET_FIELD_AGGREGATION_DATA(state, { fieldId, value: newValue }) { const current = state.fieldAggregationData[fieldId] || { loading: false, } state.fieldAggregationData = { ...state.fieldAggregationData, [fieldId]: { ...current, value: newValue }, } }, SET_FIELD_AGGREGATION_DATA_LOADING( state, { fieldId, value: newLoadingValue } ) { const current = state.fieldAggregationData[fieldId] || { value: null, } state.fieldAggregationData = { ...state.fieldAggregationData, [fieldId]: { ...current, loading: newLoadingValue }, } }, /** * Overwrites the group by metadata. This should be done when all the rows in the * buffer are refreshed. */ SET_GROUP_BY_METADATA(state, metadata) { state.groupByMetadata = metadata }, /** * Merges the existing group by metadata and the newly provided metadata. If a * count for the value combination already exists, it will be updated, otherwise * it will be created. */ UPDATE_GROUP_BY_METADATA(state, newMetadata) { const existingMetadata = state.groupByMetadata const getFields = (object) => { const newObject = {} Object.keys(object) .filter((key) => key.startsWith('field_')) .forEach((key) => { newObject[key] = object[key] }) return newObject } Object.keys(newMetadata).forEach((newGroupField) => { newMetadata[newGroupField].forEach((newGroupEntry) => { const newGroupEntryValues = getFields(newGroupEntry) const existingIndex = existingMetadata[newGroupField].findIndex( (existingGroupEntry) => { const existingGroupEntryValues = getFields(existingGroupEntry) return _.isEqual(newGroupEntryValues, existingGroupEntryValues) } ) if (existingIndex !== -1) { Vue.set(existingMetadata[newGroupField], existingIndex, newGroupEntry) } else { existingMetadata[newGroupField].push(newGroupEntry) } }) }) }, /** * Increases or decreases the count of all group entries that match the row values. */ UPDATE_GROUP_BY_METADATA_COUNT( state, { fields, registry, row, increase, decrease } ) { const groupBys = state.activeGroupBys const existingMetadata = state.groupByMetadata groupBys.forEach((groupBy, groupByIndex) => { let updated = false const groupByFields = groupBys .slice(0, groupByIndex + 1) .map((groupBy) => { return fields.find((f) => f.id === groupBy.field) }) const entries = existingMetadata[`field_${groupBy.field}`] || [] entries.forEach((entry, index) => { const equal = fieldValuesAreEqualInObjects( groupByFields, registry, entry, row, true ) if (equal) { let count = entry.count if (increase) { count += 1 } if (decrease) { count -= 1 } Vue.set(entry, 'count', count) updated = true } }) if (!updated && increase) { const newEntry = { count: 1 } groupByFields.forEach((field) => { const key = `field_${field.id}` const fieldType = registry.get('field', field.type) newEntry[key] = fieldType.getGroupValueFromRowValue(field, row[key]) }) existingMetadata[`field_${groupBy.field}`].push(newEntry) } }) }, SET_PENDING_FIELD_OPERATIONS(state, { fieldId, rowIds, value }) { const addKey = (fieldId, rowId) => { const key = getPendingOperationKey(fieldId, rowId) Vue.set(state.pendingFieldOps, key, [fieldId, rowId]) } const deleteKey = (fieldId, rowId) => { const key = getPendingOperationKey(fieldId, rowId) Vue.delete(state.pendingFieldOps, key) } const operation = value ? addKey : deleteKey rowIds.forEach((rowId) => operation(fieldId, rowId)) }, CLEAR_PENDING_FIELD_OPERATIONS(state, { fieldIds, rowId }) { fieldIds.forEach((fieldId) => { const key = getPendingOperationKey(fieldId, rowId) Vue.delete(state.pendingFieldOps, key) }) }, UPDATE_ROW_HEIGHT(state, value) { state.rowHeight = value }, } // Contains the info needed for the delayed scroll top action. const fireScrollTop = { last: Date.now(), timeout: null, processing: false, distance: 0, } const createAndUpdateRowQueue = new GroupTaskQueue() // Contains the last row request to be able to cancel it. let lastRequest = null let lastRequestOffset = null let lastRequestLimit = null let lastRefreshRequest = null let lastRefreshRequestController = null let lastQueryController = null // We want to cancel previous aggregation request before creating a new one. const lastAggregationRequest = { request: null, controller: null } export const actions = { /** * This action calculates which rows we would like to have in the buffer based on * the scroll top offset and the window height. Based on that is calculates which * rows we need to fetch compared to what we already have. If we need to fetch * anything other then we already have or waiting for a new request will be made. */ fetchByScrollTop( { commit, getters, rootGetters, dispatch }, { scrollTop, fields } ) { const windowHeight = getters.getWindowHeight const gridId = getters.getLastGridId const view = rootGetters['view/get'](getters.getLastGridId) // Calculate what the middle row index of the visible window based on the scroll // top. const middle = scrollTop + windowHeight / 2 const countIndex = getters.getCount - 1 const middleRowIndex = Math.min( Math.max(Math.ceil(middle / getters.getRowHeight) - 1, 0), countIndex ) // Calculate the start and end index of the rows that are visible to the user in // the whole database. const visibleStartIndex = Math.max( middleRowIndex - getters.getRowPadding, 0 ) const visibleEndIndex = Math.min( middleRowIndex + getters.getRowPadding, countIndex ) // Calculate the start and end index of the buffer, which are the rows that we // load in the memory of the browser, based on all the rows in the database. const bufferRequestSize = getters.getBufferRequestSize const bufferStartIndex = Math.max( Math.ceil((visibleStartIndex - bufferRequestSize) / bufferRequestSize) * bufferRequestSize, 0 ) const bufferEndIndex = Math.min( Math.ceil((visibleEndIndex + bufferRequestSize) / bufferRequestSize) * bufferRequestSize, getters.getCount ) const bufferLimit = bufferEndIndex - bufferStartIndex // Determine if the user is scrolling up or down. const down = bufferStartIndex > getters.getBufferStartIndex || bufferEndIndex > getters.getBufferEndIndex const up = bufferStartIndex < getters.getBufferStartIndex || bufferEndIndex < getters.getBufferEndIndex let prependToBuffer = 0 let appendToBuffer = 0 let requestOffset = null let requestLimit = null // Calculate how many rows we want to add and remove from the current rows buffer in // the store if the buffer would transition to the desired state. Also the // request offset and limit are calculated for the next request based on what we // currently have in the buffer. if (down) { prependToBuffer = Math.max( -getters.getBufferLimit, getters.getBufferStartIndex - bufferStartIndex ) appendToBuffer = Math.min( bufferLimit, bufferEndIndex - getters.getBufferEndIndex ) requestOffset = Math.max(getters.getBufferEndIndex, bufferStartIndex) requestLimit = appendToBuffer } else if (up) { prependToBuffer = Math.min( bufferLimit, getters.getBufferStartIndex - bufferStartIndex ) appendToBuffer = Math.max( -getters.getBufferLimit, bufferEndIndex - getters.getBufferEndIndex ) requestOffset = Math.max(bufferStartIndex, 0) requestLimit = prependToBuffer } // Checks if we need to request anything and if there are any changes since the // last request we made. If so we need to initialize a new request. if ( requestLimit > 0 && (lastRequestOffset !== requestOffset || lastRequestLimit !== requestLimit) ) { fireScrollTop.processing = true // If another request is running we need to cancel that one because it won't // what we need at the moment. if (lastRequest !== null) { lastQueryController.abort() } // Doing the actual request and remember what we are requesting so we can compare // it when making a new request. lastRequestOffset = requestOffset lastRequestLimit = requestLimit lastQueryController = new AbortController() lastRequest = GridService(this.$client) .fetchRows({ gridId, offset: requestOffset, limit: requestLimit, signal: lastQueryController.signal, search: getters.getServerSearchTerm, searchMode: getDefaultSearchModeFromEnv(this.$config), publicUrl: rootGetters['page/view/public/getIsPublic'], publicAuthToken: rootGetters['page/view/public/getAuthToken'], groupBy: getGroupBy(rootGetters, getters.getLastGridId), orderBy: getOrderBy(view, getters.getAdhocSorting), filters: getFilters(view, getters.getAdhocFiltering), }) .then(({ data }) => { data.results.forEach((row) => { const metadata = extractRowMetadata(data, row.id) populateRow(row, metadata) }) commit('ADD_ROWS', { rows: data.results, prependToRows: prependToBuffer, appendToRows: appendToBuffer, count: data.count, bufferStartIndex, bufferLimit, }) commit('UPDATE_GROUP_BY_METADATA', data.group_by_metadata || {}) dispatch('visibleByScrollTop') dispatch('updateSearch', { fields }) lastRequest = null fireScrollTop.processing = false }) .catch((error) => { if (!axios.isCancel(error)) { lastRequest = null throw error } fireScrollTop.processing = false }) } }, /** * Calculates which rows should be visible for the user based on the provided * scroll top and window height. Because we know what the padding above and below * the middle row should be and which rows we have in the buffer we can calculate * what the start and end index for the visible rows in the buffer should be. */ visibleByScrollTop({ getters, commit }, scrollTop = null) { if (scrollTop !== null) { commit('SET_SCROLL_TOP', scrollTop) } else { scrollTop = getters.getScrollTop } const windowHeight = getters.getWindowHeight const middle = scrollTop + windowHeight / 2 const countIndex = getters.getCount - 1 const middleRowIndex = Math.min( Math.max(Math.ceil(middle / getters.getRowHeight) - 1, 0), countIndex ) // Calculate the start and end index of the rows that are visible to the user in // the whole table. const visibleStartIndex = Math.max( middleRowIndex - getters.getRowPadding, 0 ) const visibleEndIndex = Math.min( middleRowIndex + getters.getRowPadding + 1, getters.getCount ) // Calculate the start and end index of the buffered rows that are visible for // the user. const visibleRowStartIndex = Math.min( Math.max(visibleStartIndex, getters.getBufferStartIndex), getters.getBufferEndIndex ) - getters.getBufferStartIndex const visibleRowEndIndex = Math.max( Math.min(visibleEndIndex, getters.getBufferEndIndex), getters.getBufferStartIndex ) - getters.getBufferStartIndex // Calculate the top position of the html element that contains all the rows. // This element will be placed over the placeholder the correct position of // those rows. const top = Math.min(visibleStartIndex, getters.getBufferEndIndex) * getters.getRowHeight // If the index changes from what we already have we can commit the new indexes // to the state. if ( visibleRowStartIndex !== getters.getRowsStartIndex || visibleRowEndIndex !== getters.getRowsEndIndex || top !== getters.getRowsTop ) { commit('SET_ROWS_INDEX', { startIndex: visibleRowStartIndex, endIndex: visibleRowEndIndex, top, }) } }, /** * This action is called every time the users scrolls which might result in a lot * of calls. Therefore it will dispatch the related actions, but only every 100 * milliseconds to prevent calling the actions who do a lot of calculating a lot. */ fetchByScrollTopDelayed({ dispatch }, { scrollTop, fields }) { const now = Date.now() const fire = (scrollTop) => { fireScrollTop.distance = scrollTop fireScrollTop.last = now dispatch('fetchByScrollTop', { scrollTop, fields, }) dispatch('visibleByScrollTop', scrollTop) } const distance = Math.abs(scrollTop - fireScrollTop.distance) const timeDelta = now - fireScrollTop.last const velocity = distance / timeDelta if (!fireScrollTop.processing && timeDelta > 100 && velocity < 2.5) { clearTimeout(fireScrollTop.timeout) fire(scrollTop) } else { // Allow velocity calculation on last ~100 ms if (timeDelta > 100) { fireScrollTop.distance = scrollTop fireScrollTop.last = now } clearTimeout(fireScrollTop.timeout) fireScrollTop.timeout = setTimeout(() => { fire(scrollTop) }, 100) } }, /** * Fetches an initial set of rows and adds that data to the store. */ async fetchInitial( { dispatch, commit, getters, rootGetters }, { gridId, fields, adhocFiltering, adhocSorting } ) { // Reset scrollTop when switching table fireScrollTop.distance = 0 fireScrollTop.last = Date.now() fireScrollTop.processing = false commit('SET_SEARCH', { activeSearchTerm: '', hideRowsNotMatchingSearch: true, }) commit('SET_LAST_GRID_ID', gridId) commit('SET_ADHOC_FILTERING', adhocFiltering) commit('SET_ADHOC_SORTING', adhocSorting) const view = rootGetters['view/get'](getters.getLastGridId) const limit = getters.getBufferRequestSize * 2 const { data } = await GridService(this.$client).fetchRows({ gridId, offset: 0, limit, includeFieldOptions: true, search: getters.getServerSearchTerm, searchMode: getDefaultSearchModeFromEnv(this.$config), publicUrl: rootGetters['page/view/public/getIsPublic'], publicAuthToken: rootGetters['page/view/public/getAuthToken'], groupBy: getGroupBy(rootGetters, getters.getLastGridId), orderBy: getOrderBy(view, adhocSorting), filters: getFilters(view, adhocFiltering), }) data.results.forEach((row) => { const metadata = extractRowMetadata(data, row.id) populateRow(row, metadata) }) commit('CLEAR_ROWS') commit('ADD_ROWS', { rows: data.results, prependToRows: 0, appendToRows: data.results.length, count: data.count, bufferStartIndex: 0, bufferLimit: data.count > limit ? limit : data.count, }) commit('SET_ROWS_INDEX', { startIndex: 0, // @TODO mut calculate how many rows would fit and based on that calculate // what the end index should be. endIndex: data.count > 31 ? 31 : data.count, top: 0, }) commit('REPLACE_ALL_FIELD_OPTIONS', data.field_options) commit('SET_GROUP_BY_METADATA', data.group_by_metadata || {}) dispatch('updateSearch', { fields }) }, /** * Refreshes the current state with fresh data. It keeps the scroll offset the same * if possible. This can be used when a new filter or sort is created. Will also * update search highlighting if a new activeSearchTerm and hideRowsNotMatchingSearch * are provided in the refreshEvent. */ refresh( { dispatch, commit, getters, rootGetters }, { view, fields, adhocFiltering, adhocSorting, includeFieldOptions = false } ) { commit('SET_ADHOC_FILTERING', adhocFiltering) commit('SET_ADHOC_SORTING', adhocSorting) const gridId = getters.getLastGridId if (lastRefreshRequest !== null) { lastRefreshRequestController.abort() } lastRefreshRequestController = new AbortController() lastRefreshRequest = GridService(this.$client) .fetchCount({ gridId, search: getters.getServerSearchTerm, searchMode: getDefaultSearchModeFromEnv(this.$config), signal: lastRefreshRequestController.signal, publicUrl: rootGetters['page/view/public/getIsPublic'], publicAuthToken: rootGetters['page/view/public/getAuthToken'], filters: getFilters(view, adhocFiltering), }) .then((response) => { const count = response.data.count const limit = getters.getBufferRequestSize * 3 const bufferEndIndex = getters.getBufferEndIndex const offset = count >= bufferEndIndex ? getters.getBufferStartIndex : Math.max(0, count - limit) return { limit, offset } }) .then(({ limit, offset }) => GridService(this.$client) .fetchRows({ gridId, offset, limit, includeFieldOptions, signal: lastRefreshRequestController.signal, search: getters.getServerSearchTerm, searchMode: getDefaultSearchModeFromEnv(this.$config), publicUrl: rootGetters['page/view/public/getIsPublic'], publicAuthToken: rootGetters['page/view/public/getAuthToken'], groupBy: getGroupBy(rootGetters, getters.getLastGridId), orderBy: getOrderBy(view, adhocSorting), filters: getFilters(view, adhocFiltering), }) .then(({ data }) => ({ data, offset, })) ) .then(({ data, offset }) => { // If there are results we can replace the existing rows so that the user stays // at the same scroll offset. data.results.forEach((row) => { const metadata = extractRowMetadata(data, row.id) populateRow(row, metadata) }) commit('ADD_ROWS', { rows: data.results, prependToRows: -getters.getBufferLimit, appendToRows: data.results.length, count: data.count, bufferStartIndex: offset, bufferLimit: data.results.length, }) commit('SET_GROUP_BY_METADATA', data.group_by_metadata || {}) dispatch('updateSearch', { fields }) if (includeFieldOptions) { if (rootGetters['page/view/public/getIsPublic']) { commit('REPLACE_PUBLIC_FIELD_OPTIONS', data.field_options) } else { commit('REPLACE_ALL_FIELD_OPTIONS', data.field_options) } } dispatch('correctMultiSelect') dispatch('fetchAllFieldAggregationData', { view, }) lastRefreshRequest = null }) .catch((error) => { if (axios.isCancel(error)) { throw new RefreshCancelledError() } else { lastRefreshRequest = null throw error } }) return lastRefreshRequest }, updateActiveGroupBys({ commit }, groupBys) { commit('SET_ACTIVE_GROUP_BYS', groupBys) }, /** * Updates the field options of a given field and also makes an API request to the * backend with the changed values. If the request fails the action is reverted. */ async updateFieldOptionsOfField( { commit, getters, dispatch, rootGetters }, { field, values, oldValues, readOnly = false, undoRedoActionGroupId } ) { const previousOptions = getters.getAllFieldOptions[field.id] let needAggregationValueUpdate = false /** * If the aggregation raw type has changed, we delete the corresponding the * aggregation value from the store. */ if ( Object.prototype.hasOwnProperty.call(values, 'aggregation_raw_type') && values.aggregation_raw_type !== previousOptions.aggregation_raw_type ) { needAggregationValueUpdate = true commit('SET_FIELD_AGGREGATION_DATA', { fieldId: field.id, value: null }) commit('SET_FIELD_AGGREGATION_DATA_LOADING', { fieldId: field.id, value: true, }) } commit('UPDATE_FIELD_OPTIONS_OF_FIELD', { fieldId: field.id, values, }) const gridId = getters.getLastGridId if (!readOnly) { const updateValues = { field_options: {} } updateValues.field_options[field.id] = values try { await ViewService(this.$client).updateFieldOptions({ viewId: gridId, values: updateValues, undoRedoActionGroupId, }) } catch (error) { commit('UPDATE_FIELD_OPTIONS_OF_FIELD', { fieldId: field.id, values: oldValues, }) throw error } finally { if (needAggregationValueUpdate && values.aggregation_type) { dispatch('fetchAllFieldAggregationData', { view: { id: gridId } }) } } } }, /** * Updates the field options of a given field in the store. So no API request to * the backend is made. */ setFieldOptionsOfField({ commit, getters, dispatch }, { field, values }) { commit('UPDATE_FIELD_OPTIONS_OF_FIELD', { fieldId: field.id, values, }) dispatch('correctMultiSelect') }, /** * Replaces all field options with new values and also makes an API request to the * backend with the changed values. If the request fails the action is reverted. */ async updateAllFieldOptions( { dispatch, getters, rootGetters }, { newFieldOptions, oldFieldOptions, readOnly = false, undoRedoActionGroupId = null, } ) { dispatch('forceUpdateAllFieldOptions', newFieldOptions) const gridId = getters.getLastGridId if (!readOnly) { const updateValues = { field_options: newFieldOptions } try { await ViewService(this.$client).updateFieldOptions({ viewId: gridId, values: updateValues, undoRedoActionGroupId, }) } catch (error) { dispatch('forceUpdateAllFieldOptions', oldFieldOptions) dispatch('correctMultiSelect') throw error } } }, /** * Forcefully updates all field options without making a call to the backend. */ forceUpdateAllFieldOptions({ commit, dispatch }, fieldOptions) { commit('UPDATE_ALL_FIELD_OPTIONS', fieldOptions) dispatch('correctMultiSelect') }, /** * Fetch all field aggregation data from the server for this view. Set loading state * to true while doing the query. Do nothing if this is a public view or if there is * no aggregation at all. If the query goes in error, the values are set to `null` * to prevent wrong information. * If a request is already in progress, it is aborted in favour of the new one. */ async fetchAllFieldAggregationData( { rootGetters, getters, commit }, { view } ) { const isPublic = rootGetters['page/view/public/getIsPublic'] const search = getters.getActiveSearchTerm const fieldOptions = getters.getAllFieldOptions let atLeastOneAggregation = false Object.entries(fieldOptions).forEach(([fieldId, options]) => { if (options.aggregation_raw_type) { commit('SET_FIELD_AGGREGATION_DATA_LOADING', { fieldId, value: true, }) atLeastOneAggregation = true } }) if (!atLeastOneAggregation) { return } try { if (lastAggregationRequest.request !== null) { lastAggregationRequest.controller.abort() } lastAggregationRequest.controller = new AbortController() if (!isPublic) { lastAggregationRequest.request = GridService( this.$client ).fetchFieldAggregations({ gridId: view.id, filters: getFilters(view, getters.getAdhocFiltering), search, searchMode: getDefaultSearchModeFromEnv(this.$config), signal: lastAggregationRequest.controller.signal, }) } else { lastAggregationRequest.request = GridService( this.$client ).fetchPublicFieldAggregations({ slug: view.slug, publicAuthToken: rootGetters['page/view/public/getAuthToken'], filters: getFilters(view, getters.getAdhocFiltering), search, searchMode: getDefaultSearchModeFromEnv(this.$config), signal: lastAggregationRequest.controller.signal, }) } const { data } = await lastAggregationRequest.request lastAggregationRequest.request = null Object.entries(fieldOptions).forEach(([fieldId, options]) => { if (options.aggregation_raw_type) { commit('SET_FIELD_AGGREGATION_DATA', { fieldId, value: data[`field_${fieldId}`], }) } }) Object.entries(fieldOptions).forEach(([fieldId, options]) => { if (options.aggregation_raw_type) { commit('SET_FIELD_AGGREGATION_DATA_LOADING', { fieldId, value: false, }) } }) } catch (error) { if (!axios.isCancel(error)) { lastAggregationRequest.request = null // Emptied the values Object.entries(fieldOptions).forEach(([fieldId, options]) => { if (options.aggregation_raw_type) { commit('SET_FIELD_AGGREGATION_DATA', { fieldId, value: null, }) } }) // Remove loading state Object.entries(fieldOptions).forEach(([fieldId, options]) => { if (options.aggregation_raw_type) { commit('SET_FIELD_AGGREGATION_DATA_LOADING', { fieldId, value: false, }) } }) throw error } } }, /** * Updates the order of all the available field options. The provided order parameter * should be an array containing the field ids in the correct order. */ async updateFieldOptionsOrder( { commit, getters, dispatch }, { order, readOnly = false, undoRedoActionGroupId = null } ) { const oldFieldOptions = clone(getters.getAllFieldOptions) const newFieldOptions = clone(getters.getAllFieldOptions) // Update the order of the field options that have not been provided in the order. // They will get a position that places them after the provided field ids. let i = 0 Object.keys(newFieldOptions).forEach((fieldId) => { if (!order.includes(parseInt(fieldId))) { newFieldOptions[fieldId].order = order.length + i i++ } }) // Update create the field options and set the correct order value. order.forEach((fieldId, index) => { const id = fieldId.toString() if (Object.prototype.hasOwnProperty.call(newFieldOptions, id)) { newFieldOptions[fieldId.toString()].order = index } }) return await dispatch('updateAllFieldOptions', { oldFieldOptions, newFieldOptions, readOnly, undoRedoActionGroupId, }) }, /** * Move one field on the left or the right of the specified `fromField` * by updating all the fieldOptions orders. * * @param {object} fieldToMove The field that is going to be moved. * @param {string} position Set to 'left' to move the field to the left of the * fromField. The field is moved to the right otherwise. * @param {object} fromField We want to move the `fieldtoMove` relatively to this * field. * If `position` === 'left' the `fieldToMove` is going to be * positioned at the left of the specified `fromField` * otherwise to the right of this field. * @param {string} undoRedoActionGroupId An optional undo/redo group action. * @param {boolean} readOnly Set to true to not send the modification to the server. */ async updateSingleFieldOptionOrder( { getters, dispatch }, { fieldToMove, position = 'left', fromField, undoRedoActionGroupId = null, readOnly = false, } ) { const oldFieldOptions = clone(getters.getAllFieldOptions) const newFieldOptions = clone(getters.getAllFieldOptions) // Order field options by order then by fieldId const orderedFieldOptions = Object.entries(newFieldOptions) .map(([fieldIdStr, options]) => [parseInt(fieldIdStr), options]) .sort(([a, { order: orderA }], [b, { order: orderB }]) => { // First by order. if (orderA > orderB) { return 1 } else if (orderA < orderB) { return -1 } return a - b }) let index = 0 // Update order of all fieldOptions inserting the movedField to the right position orderedFieldOptions.forEach(([fieldId, options]) => { if (fieldId === fromField.id) { // Update firstField and second field order if (position === 'left') { newFieldOptions[fieldToMove.id].order = index newFieldOptions[fromField.id].order = index + 1 } else { newFieldOptions[fromField.id].order = index newFieldOptions[fieldToMove.id].order = index + 1 } index += 2 } else if (fieldId !== fieldToMove.id) { // Update all other field order options.order = index index += 1 } }) return await dispatch('updateAllFieldOptions', { oldFieldOptions, newFieldOptions, readOnly, undoRedoActionGroupId, }) }, /** * Deletes the field options of the provided field id if they exist. */ forceDeleteFieldOptions({ commit, dispatch }, fieldId) { commit('DELETE_FIELD_OPTIONS', fieldId) dispatch('correctMultiSelect') }, setWindowHeight({ dispatch, commit, getters }, value) { commit('SET_WINDOW_HEIGHT', value) commit('SET_ROW_PADDING', Math.ceil(value / getters.getRowHeight / 2)) dispatch('visibleByScrollTop') }, setAddRowHover({ commit }, value) { commit('SET_ADD_ROW_HOVER', value) }, setSelectedCell( { commit, getters, rootGetters }, { rowId, fieldId, fields } ) { commit('SET_SELECTED_CELL', { rowId, fieldId }) const rowIndex = getters.getRowIndexById(rowId) if (rowIndex !== -1) { commit('SET_MULTISELECT_START_ROW_INDEX', rowIndex) const visibleFieldOptions = getters.getOrderedVisibleFieldOptions(fields) commit( 'SET_MULTISELECT_START_FIELD_INDEX', visibleFieldOptions.findIndex((f) => parseInt(f[0]) === fieldId) ) } }, setSelectedCellCancelledMultiSelect( { commit, getters, rootGetters, dispatch }, { direction, fields } ) { const rowIndex = getters.getMultiSelectStartRowIndex const fieldIndex = getters.getMultiSelectStartFieldIndex const [newRowIndex, newFieldIndex] = updatePositionFn[direction]( rowIndex, fieldIndex ) const rows = getters.getAllRows const visibleFieldEntries = getters.getOrderedVisibleFieldOptions(fields) const row = rows[newRowIndex - getters.getBufferStartIndex] const field = visibleFieldEntries[newFieldIndex] if (row && field) { dispatch('setSelectedCell', { rowId: row.id, fieldId: parseInt(field[0]), fields, }) } else { const oldRow = rows[rowIndex - getters.getBufferStartIndex] const oldField = visibleFieldEntries[fieldIndex] if (oldRow && oldField) { dispatch('setSelectedCell', { rowId: oldRow.id, fieldId: parseInt(oldField[0]), fields, }) } } dispatch('clearAndDisableMultiSelect') }, setMultiSelectHolding({ commit }, value) { commit('SET_MULTISELECT_HOLDING', value) }, setMultiSelectActive({ commit }, value) { commit('SET_MULTISELECT_ACTIVE', value) }, clearAndDisableMultiSelect({ commit }) { commit('CLEAR_MULTISELECT') commit('SET_MULTISELECT_ACTIVE', false) }, multiSelectStart({ getters, commit, dispatch }, { rowId, fieldIndex }) { commit('CLEAR_MULTISELECT') const rowIndex = getters.getRowIndexById(rowId) // Set the head and tail index to highlight the first cell dispatch('updateMultipleSelectIndexes', { position: 'head', rowIndex, fieldIndex, }) dispatch('updateMultipleSelectIndexes', { position: 'tail', rowIndex, fieldIndex, }) commit('CLEAR_MULTISELECT_START') commit('SET_MULTISELECT_START_ROW_INDEX', rowIndex) commit('SET_MULTISELECT_START_FIELD_INDEX', fieldIndex) // Update the store to show that the mouse is being held for multi-select commit('SET_MULTISELECT_HOLDING', true) // Do not enable multi-select if only a single cell is selected commit('SET_MULTISELECT_ACTIVE', false) }, multiSelectShiftClick( { state, getters, commit, dispatch }, { rowId, fieldIndex } ) { commit('SET_MULTISELECT_ACTIVE', true) dispatch('setMultiSelectHeadOrTail', { rowId, fieldIndex }) }, multiSelectShiftChange({ getters, commit, dispatch }, { direction }) { if ( getters.getMultiSelectStartRowIndex === -1 || getters.getMultiSelectStartFieldIndex === -1 ) { return { position: null, rowIndex: -1, fieldIndex: -1, } } if (!getters.isMultiSelectActive) { commit('SET_MULTISELECT_ACTIVE', true) dispatch('updateMultipleSelectIndexes', { position: 'head', rowIndex: getters.getMultiSelectStartRowIndex, fieldIndex: getters.getMultiSelectStartFieldIndex, }) dispatch('updateMultipleSelectIndexes', { position: 'tail', rowIndex: getters.getMultiSelectStartRowIndex, fieldIndex: getters.getMultiSelectStartFieldIndex, }) commit('SET_SELECTED_CELL', { rowId: -1, fieldId: -1 }) } const tailRowIndex = getters.getMultiSelectTailRowIndex const tailFieldIndex = getters.getMultiSelectTailFieldIndex const headRowIndex = getters.getMultiSelectHeadRowIndex const headFieldIndex = getters.getMultiSelectHeadFieldIndex const [newRowTailIndex, newFieldTailIndex] = updatePositionFn[direction]( tailRowIndex, tailFieldIndex ) const [newRowHeadIndex, newFieldHeadIndex] = updatePositionFn[direction]( headRowIndex, headFieldIndex ) let positionToMove if (direction === 'below') { if (headRowIndex === getters.getMultiSelectStartRowIndex) { positionToMove = 'tail' } else { positionToMove = 'head' } } if (direction === 'above') { if (tailRowIndex === getters.getMultiSelectStartRowIndex) { positionToMove = 'head' } else { positionToMove = 'tail' } } if (direction === 'previous') { if (tailFieldIndex === getters.getMultiSelectStartFieldIndex) { positionToMove = 'head' } else { positionToMove = 'tail' } } if (direction === 'next') { if (headFieldIndex === getters.getMultiSelectStartFieldIndex) { positionToMove = 'tail' } else { positionToMove = 'head' } } dispatch('updateMultipleSelectIndexes', { position: positionToMove, rowIndex: positionToMove === 'tail' ? newRowTailIndex : newRowHeadIndex, fieldIndex: positionToMove === 'tail' ? newFieldTailIndex : newFieldHeadIndex, }) return { position: positionToMove, rowIndex: positionToMove === 'tail' ? newRowTailIndex : newRowHeadIndex, fieldIndex: positionToMove === 'tail' ? newFieldTailIndex : newFieldHeadIndex, } }, multiSelectHold({ getters, commit, dispatch }, { rowId, fieldIndex }) { if (getters.isMultiSelectHolding) { dispatch('setMultiSelectHeadOrTail', { rowId, fieldIndex }) } }, setMultiSelectHeadOrTail( { getters, commit, dispatch }, { rowId, fieldIndex } ) { commit('SET_SELECTED_CELL', { rowId: -1, fieldId: -1 }) const rowIndex = getters.getRowIndexById(rowId) const startRowIndex = getters.getMultiSelectStartRowIndex const startFieldIndex = getters.getMultiSelectStartFieldIndex const newHeadRowIndex = Math.min(startRowIndex, rowIndex) const newHeadFieldIndex = Math.min(startFieldIndex, fieldIndex) const newTailRowIndex = Math.max(startRowIndex, rowIndex) const newTailFieldIndex = Math.max(startFieldIndex, fieldIndex) dispatch('updateMultipleSelectIndexes', { position: 'head', rowIndex: newHeadRowIndex, fieldIndex: newHeadFieldIndex, }) dispatch('updateMultipleSelectIndexes', { position: 'tail', rowIndex: newTailRowIndex, fieldIndex: newTailFieldIndex, }) commit('SET_MULTISELECT_ACTIVE', true) }, correctMultiSelect({ getters, commit }) { const headRowIndex = getters.getMultiSelectHeadRowIndex const tailRowIndex = getters.getMultiSelectTailRowIndex const headFieldIndex = getters.getMultiSelectHeadFieldIndex const tailFieldIndex = getters.getMultiSelectTailFieldIndex const startRowIndex = getters.getMultiSelectStartRowIndex const startFieldIndex = getters.getMultiSelectStartFieldIndex const maxRowIndex = getters.getRowsLength + getters.getBufferStartIndex - 1 const maxFieldIndex = getters.getNumberOfVisibleFields - 1 if (headRowIndex > maxRowIndex || headFieldIndex > maxFieldIndex) { commit('CLEAR_MULTISELECT') commit('CLEAR_MULTISELECT_START') return } commit('UPDATE_MULTISELECT', { position: 'tail', rowIndex: tailRowIndex > maxRowIndex ? maxRowIndex : tailRowIndex, fieldIndex: tailFieldIndex > maxFieldIndex ? maxFieldIndex : tailFieldIndex, }) const newStartRowIndex = startRowIndex > maxRowIndex ? maxRowIndex : startRowIndex const newStartFieldIndex = startFieldIndex > maxFieldIndex ? maxFieldIndex : startFieldIndex commit('SET_MULTISELECT_START_ROW_INDEX', newStartRowIndex) commit('SET_MULTISELECT_START_FIELD_INDEX', newStartFieldIndex) }, /** * Returns the fields and rows necessaries to extract data from the selection. * It only contains the rows and fields selected by the multiple select. * If one or more rows are not in the buffer, they are fetched from the backend. */ async getCurrentSelection({ dispatch, getters }, { fields }) { const [minFieldIndex, maxFieldIndex] = getters.getMultiSelectFieldIndexSorted 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, }) } return [fields, rows] }, /** * 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, excludeFields } ) { if (fields !== undefined) { fields = fields.map((field) => `field_${field.id}`) } if (excludeFields !== undefined) { excludeFields = excludeFields.map((field) => `field_${field.id}`) } const gridId = getters.getLastGridId const view = rootGetters['view/get'](getters.getLastGridId) const { data } = await GridService(this.$client).fetchRows({ gridId, offset: startIndex, limit, search: getters.getServerSearchTerm, searchMode: getDefaultSearchModeFromEnv(this.$config), publicUrl: rootGetters['page/view/public/getIsPublic'], publicAuthToken: rootGetters['page/view/public/getAuthToken'], groupBy: getGroupBy(rootGetters, getters.getLastGridId), orderBy: getOrderBy(view, getters.getAdhocSorting), filters: getFilters(view, getters.getAdhocFiltering), includeFields: fields, excludeFields, }) return data.results }, setRowHover({ commit }, { row, value }) { commit('SET_ROW_HOVER', { row, value }) }, /** * Adds a field with a provided value to the rows in memory. */ addField({ commit }, { field, value = null }) { commit('ADD_FIELD_TO_ROWS_IN_BUFFER', { field, value }) }, /** * Adds a field to the list of selected fields of a row. We use this to indicate * if a row is selected or not. */ addRowSelectedBy({ commit }, { row, field }) { commit('ADD_ROW_SELECTED_BY', { row, fieldId: field.id }) }, /** * Removes a field from the list of selected fields of a row. We use this to * indicate if a row is selected or not. If the field is not selected anymore * and it does not match the filters it can be removed from the store. */ removeRowSelectedBy( { dispatch, commit }, { grid, row, field, fields, getScrollTop, isRowOpenedInModal = false } ) { commit('REMOVE_ROW_SELECTED_BY', { row, fieldId: field.id }) dispatch('refreshRow', { grid, row, fields, getScrollTop, isRowOpenedInModal, }) }, /** * Used when row data needs to be directly re-fetched from the Backend and * the other (background) row needs to be refreshed. For example, when editing * row from a *different* table using ForeignRowEditModal or just RowEditModal * component in general. */ async refreshRowFromBackend({ commit, getters, dispatch }, { table, row }) { const { data } = await RowService(this.$client).get(table.id, row.id) // Use the return value to update the desired row with latest values from the // backend. commit('UPDATE_ROW_IN_BUFFER', { row, values: data }) }, /** * Called when the user wants to create a new row. Optionally a `before` row * object can be provided which will forcefully add the row before that row. If no * `before` is provided, the row will be added last. */ async createNewRow( { commit, getters, dispatch }, { view, table, fields, values = {}, before = null, selectPrimaryCell = false, } ) { await dispatch('createNewRows', { view, table, fields, rows: [values], before, selectPrimaryCell, }) }, async createNewRows( { commit, getters, dispatch }, { view, table, fields, rows = {}, before = null, selectPrimaryCell = false } ) { // Create an object of default field values that can be used to fill the row with // missing default values const fieldNewRowValueMap = fields.reduce((map, field) => { const name = `field_${field.id}` const fieldType = this.$registry.get('field', field._.type.type) map[name] = fieldType.getNewRowValue(field) return map }, {}) const step = before ? ORDER_STEP_BEFORE : ORDER_STEP // If before is not provided, then the row is added last. Because we don't know // the total amount of rows in the table, we are going to add find the highest // existing order in the buffer and increase that by one. let order = getters.getHighestOrder .integerValue(BigNumber.ROUND_CEIL) .plus(step) .toString() if (before !== null) { // It's okay to temporary set an order that just subtracts the // ORDER_STEP_BEFORE because there will never be a conflict with rows because // of the fraction ordering. order = new BigNumber(before.order) .minus(new BigNumber(step * rows.length)) .toString() } const index = before === null ? getters.getBufferEndIndex : getters.getAllRows.findIndex((r) => r.id === before.id) const rowsPopulated = rows.map((row) => { row = { ...clone(fieldNewRowValueMap), ...row } row = populateRow(row) row.id = uuid() row.order = order row._.loading = true order = new BigNumber(order).plus(new BigNumber(step)).toString() return row }) const isSingleRowInsertion = rowsPopulated.length === 1 const oldCount = getters.getCount if (isSingleRowInsertion) { // When a single row is inserted we don't want to deal with filters, sorts and // search just yet. Therefore it is okay to just insert the row into the buffer. commit('UPDATE_GROUP_BY_METADATA_COUNT', { fields, registry: this.$registry, row: rowsPopulated[0], increase: true, decrease: false, }) commit('INSERT_NEW_ROWS_IN_BUFFER_AT_INDEX', { rows: rowsPopulated, index, }) } else { // When inserting multiple rows we will need to deal with filters, sorts or search // not matching. `createdNewRow` deals with exactly that for us. for (const rowPopulated of rowsPopulated) { await dispatch('createdNewRow', { view, fields, values: rowPopulated, metadata: {}, populate: false, }) } } dispatch('visibleByScrollTop') // Check if not all rows are visible. const diff = oldCount - getters.getCount + rowsPopulated.length if (!isSingleRowInsertion && diff > 0) { dispatch( 'toast/success', { title: this.$i18n.t('gridView.hiddenRowsInsertedTitle'), message: this.$i18n.t('gridView.hiddenRowsInsertedMessage', { number: diff, }), }, { root: true } ) } const primaryField = fields.find((f) => f.primary) if (selectPrimaryCell && primaryField && isSingleRowInsertion) { await dispatch('setSelectedCell', { rowId: rowsPopulated[0].id, fieldId: primaryField.id, fields, }) } // The backend expects slightly different values than what we have in the row // buffer. Therefore, we need to prepare the rows before we can send them to the // backend. const rowsPrepared = rows.map((row) => { row = { ...clone(fieldNewRowValueMap), ...row } row = prepareRowForRequest(row, fields, this.$registry) return row }) // Lock the newly created rows with their persistent ID, so that if the user // changes the value before the row is created, that request is queued. rowsPopulated.forEach((row) => { createAndUpdateRowQueue.lock(row._.persistentId) }) try { const { data } = await RowService(this.$client).batchCreate( table.id, rowsPrepared, before !== null ? before.id : null ) const fieldsToFinalize = fields .filter( (field) => field.read_only || this.$registry.get('field', field._.type.type).isReadOnly ) .map((field) => `field_${field.id}`) commit('FINALIZE_ROWS_IN_BUFFER', { oldRows: rowsPopulated, newRows: data.items, fields: fieldsToFinalize, }) for (let i = 0; i < data.items.length; i += 1) { const oldRow = rowsPopulated[i] dispatch('onRowChange', { view, row: oldRow, fields }) } await dispatch('fetchAllFieldAggregationData', { view, }) } catch (error) { if (isSingleRowInsertion) { commit('UPDATE_GROUP_BY_METADATA_COUNT', { fields, registry: this.$registry, row: rowsPopulated[0], increase: false, decrease: true, }) commit('DELETE_ROW_IN_BUFFER', rowsPopulated[0]) } else { // When we have multiple rows we will need to re-evaluate where the rest of the // rows are now positioned. Therefore, we need to call `deletedExistingRow` to // deal with all the potential edge cases for (const rowPopulated of rowsPopulated) { await dispatch('deletedExistingRow', { view, fields, row: rowPopulated, }) } } throw error } finally { // Release the lock because now the update requests can come through if they // were made. Even if the rows were not created, we have to release the ids to // clear the memory. rowsPopulated.forEach((row) => { createAndUpdateRowQueue.release(row._.persistentId) }) } dispatch('fetchByScrollTopDelayed', { scrollTop: getters.getScrollTop, fields, }) }, /** * Called after a new row has been created, which could be by the user or via * another channel. It will only add the row if it belongs inside the views and it * also makes sure that row will be inserted at the correct position. */ async createdNewRow( { commit, getters, dispatch }, { view, fields, values, metadata, populate = true } ) { const row = clone(values) if (populate) { populateRow(row, metadata) } // Check if the row belongs into the current view by checking if it matches the // filters and search. await dispatch('updateMatchFilters', { view, row, fields }) await dispatch('updateSearchMatchesForRow', { row, fields }) // 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) { return } // Update the group by metadata if needed. commit('UPDATE_GROUP_BY_METADATA_COUNT', { fields, registry: this.$registry, row, increase: true, decrease: false, }) // Now that we know that the row applies to the filters, which means it belongs // in this view, we need to estimate what position it has in the table. const allRowsCopy = clone(getters.getAllRows) allRowsCopy.push(row) const sortFunction = getRowSortFunction( this.$registry, view.sortings, fields, view.group_bys ) allRowsCopy.sort(sortFunction) const index = allRowsCopy.findIndex((r) => r.id === row.id) const isFirst = index === 0 const isLast = index === allRowsCopy.length - 1 if ( // All of these scenario's mean that the row belongs in the buffer that // we have loaded currently. (isFirst && getters.getBufferStartIndex === 0) || (isLast && getters.getBufferEndIndex === getters.getCount) || (index > 0 && index < allRowsCopy.length - 1) ) { commit('INSERT_NEW_ROWS_IN_BUFFER_AT_INDEX', { rows: [row], index }) } else { if (isFirst) { // Because the row has been added before the our buffer, we need know that the // buffer start index has increased by one. commit('SET_BUFFER_START_INDEX', getters.getBufferStartIndex + 1) } // The row has been added outside of the buffer, so we can safely increase the // count. commit('SET_COUNT', getters.getCount + 1) } }, /** * Moves an existing row to the position before the provided before row. It will * update the order and makes sure that the row is inserted in the correct place. * A call to the backend will also be made to update the order persistent. */ async moveRow( { commit, dispatch, getters }, { table, grid, fields, getScrollTop, row, before = null } ) { const oldOrder = row.order // If before is not provided, then the row is added last. Because we don't know // the total amount of rows in the table, we are going to add find the highest // existing order in the buffer and increase that by one. let order = getters.getHighestOrder .integerValue(BigNumber.ROUND_CEIL) .plus('1') .toString() if (before !== null) { // If the row has been placed before another row we can specifically insert to // the row at a calculated index. const change = new BigNumber(ORDER_STEP_BEFORE) // It's okay to temporary set an order that just subtracts the // ORDER_STEP_BEFORE because there will never be a conflict with rows because // of the fraction ordering. order = new BigNumber(before.order).minus(change).toString() } // In order to make changes feel really fast, we optimistically // updated all the field values that provide a onRowMove function const fieldsToCallOnRowMove = fields const optimisticFieldValues = {} const valuesBeforeOptimisticUpdate = {} fieldsToCallOnRowMove.forEach((field) => { const fieldType = this.$registry.get('field', field._.type.type) const fieldID = `field_${field.id}` const currentFieldValue = row[fieldID] const fieldValue = fieldType.onRowMove( row, order, oldOrder, field, currentFieldValue ) if (currentFieldValue !== fieldValue) { optimisticFieldValues[fieldID] = fieldValue valuesBeforeOptimisticUpdate[fieldID] = currentFieldValue } }) dispatch('updatedExistingRow', { view: grid, fields, row, values: { order, ...optimisticFieldValues }, }) try { const { data } = await RowService(this.$client).move( table.id, row.id, before !== null ? before.id : null ) // Use the return value to update the moved row with values from // the backend commit('UPDATE_ROW_IN_BUFFER', { row, values: data }) if (before === null) { // Not having a before means that the row was moved to the end and because // that order was just an estimation, we want to update it with the real // order, otherwise there could be order conflicts in the future. commit('UPDATE_ROW_IN_BUFFER', { row, values: { order: data.order } }) } dispatch('fetchByScrollTopDelayed', { scrollTop: getScrollTop(), fields, }) dispatch('fetchAllFieldAggregationData', { view: grid }) } catch (error) { dispatch('updatedExistingRow', { view: grid, fields, row, values: { order: oldOrder, ...valuesBeforeOptimisticUpdate }, }) throw error } }, /** * Updates a grid view field value. It will immediately be updated in the store * and only if the change request fails it will revert to give a faster * experience for the user. */ async updateRowValue( { commit, dispatch, getters }, { table, view, row, field, fields, value, oldValue } ) { /** * This helper function will make sure that the values of the related row are * updated the right way. */ const updateValues = async (values) => { const rowExistsInBuffer = getters.getRow(row.id) !== undefined if (rowExistsInBuffer) { // If the row exists in the buffer, we can visually show to the user that // the values have changed, without immediately reflecting the change in // the buffer. commit('UPDATE_GROUP_BY_METADATA_COUNT', { fields, registry: this.$registry, row, increase: false, decrease: true, }) commit('UPDATE_ROW_VALUES', { row, values: { ...values }, }) commit('UPDATE_GROUP_BY_METADATA_COUNT', { fields, registry: this.$registry, row, increase: true, decrease: false, }) await dispatch('onRowChange', { view, row, fields }) } else { // If the row doesn't exist in the buffer, it could be that the new values // bring in into there. Dispatching the `updatedExistingRow` will make // sure that will happen in the right way. await dispatch('updatedExistingRow', { view, fields, row, values }) // There is a chance that the row is not in the buffer, but it does exist in // the view. In that case, the `updatedExistingRow` action has not done // anything. There is a possibility that the row is visible in the row edit // modal, but then it won't be updated, so we have to update it forcefully. commit('UPDATE_ROW_VALUES', { row, values: { ...values }, }) await dispatch('fetchByScrollTopDelayed', { scrollTop: getters.getScrollTop, fields, }) } } const { newRowValues, oldRowValues, updateRequestValues } = prepareNewOldAndUpdateRequestValues( row, fields, field, value, oldValue, this.$registry ) // Update the values before making a request to the backend to make it feel // instant for the user. await updateValues(newRowValues) try { // Add the update actual update function to the queue so that the same row // will never be updated concurrency, and so that the value won't be // updated if the row hasn't been created yet. await createAndUpdateRowQueue.add(async () => { const updatedRow = await RowService(this.$client).update( table.id, row.id, updateRequestValues ) // Extract only the read-only values because we don't want to update the other // values that might have been updated in the meantime. const readOnlyData = extractRowReadOnlyValues( updatedRow.data, fields, this.$registry ) // Update the remaining values like formula, which depend on the backend. await updateValues(readOnlyData) dispatch('fetchAllFieldAggregationData', { view, }) }, row._.persistentId) } catch (error) { await updateValues(oldRowValues) throw error } }, /** * Set the multiple select indexes using the row and field head and tail indexes. */ setMultipleSelect( { commit, dispatch }, { rowHeadIndex, fieldHeadIndex, rowTailIndex, fieldTailIndex } ) { dispatch('updateMultipleSelectIndexes', { position: 'head', rowIndex: rowHeadIndex, fieldIndex: fieldHeadIndex, }) dispatch('updateMultipleSelectIndexes', { position: 'tail', rowIndex: rowTailIndex, fieldIndex: fieldTailIndex, }) commit('SET_MULTISELECT_ACTIVE', true) commit('SET_SELECTED_CELL', { rowId: -1, fieldId: -1 }) }, /** * Action to update head or tail (position) indexes for row and field * multiple select operations. * * It will prevent updating selection to nonsense indexes by doing nothing * if a provided index isn't correct. */ updateMultipleSelectIndexes( { commit, getters }, { position, rowIndex, fieldIndex } ) { if ( (position === 'tail' && getters.getMultiSelectHeadRowIndex !== -1) || (position === 'head' && getters.getMultiSelectTailRowIndex !== -1) ) { // check if the selection would go over limit const limit = this.$config.BASEROW_ROW_PAGE_SIZE_LIMIT const previousIndex = position === 'head' ? getters.getMultiSelectTailRowIndex : getters.getMultiSelectHeadRowIndex if (Math.abs(previousIndex - rowIndex) > limit - 1) { return } } if (rowIndex < 0 || fieldIndex < 0) { return } if ( rowIndex > getters.getRowsLength + getters.getBufferStartIndex - 1 || fieldIndex > getters.getNumberOfVisibleFields - 1 ) { return } commit('UPDATE_MULTISELECT', { position, rowIndex, fieldIndex, }) }, /** * 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, allVisibleFields, allFieldsInTable, getScrollTop, textData, jsonData, rowIndex, fieldIndex, selectUpdatedCells = true, } ) { const copiedRowsCount = textData.length const copiedCellsInRowsCount = textData[0].length const isSingleCellCopied = copiedRowsCount === 1 && copiedCellsInRowsCount === 1 if (isSingleCellCopied) { // the textData and jsonData are recreated // to fill the entire multi selection const selectedRowsCount = getters.getMultiSelectTailRowIndex - getters.getMultiSelectHeadRowIndex + 1 const selectedFieldsCount = getters.getMultiSelectTailFieldIndex - getters.getMultiSelectHeadFieldIndex + 1 const rowTextArray = Array(selectedFieldsCount).fill(textData[0][0]) textData = Array(selectedRowsCount).fill(rowTextArray) if (jsonData) { const rowJsonArray = Array(selectedFieldsCount).fill(jsonData[0][0]) jsonData = Array(selectedRowsCount).fill(rowJsonArray) } } // If the 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. let rowTailIndex = Math.min(getters.getCount, rowHeadIndex + copiedRowsCount) - 1 let fieldTailIndex = Math.min( allVisibleFields.length, fieldHeadIndex + copiedCellsInRowsCount ) - 1 if (isSingleCellCopied) { // we want the tail indexes to follow the multi select exactly rowTailIndex = getters.getMultiSelectTailRowIndex fieldTailIndex = getters.getMultiSelectTailFieldIndex } const newRowsCount = copiedRowsCount - (rowTailIndex - rowHeadIndex + 1) // Create extra missing rows if (newRowsCount > 0) { await dispatch('createNewRows', { view, table, fields: allFieldsInTable, rows: Array.from(Array(newRowsCount), (element, index) => { return {} }), selectPrimaryCell: false, }) rowTailIndex = rowTailIndex + newRowsCount } if (!isSingleCellCopied && selectUpdatedCells) { // 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. await dispatch('setMultipleSelect', { rowHeadIndex, fieldHeadIndex, rowTailIndex, fieldTailIndex, }) } // Figure out which rows are already in the buffered and temporarily store them // in an array. const fieldsInOrder = allVisibleFields.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` let startIndex = rowHeadIndex if (rowHeadIndex + rowsInOrder.length >= getters.getBufferStartIndex) { startIndex += 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 textValue = textData[rowIndex][fieldIndex] const jsonValue = jsonData != null ? jsonData[rowIndex][fieldIndex] : undefined const fieldType = this.$registry.get('field', field.type) const preparedValue = fieldType.prepareValueForPaste( field, textValue, jsonValue ) 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: allFieldsInTable, 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: allFieldsInTable, }) 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 * that is will be deleted or created depending if was already in the view. */ async updatedExistingRow( { commit, getters, dispatch }, { view, fields, row, values, metadata } ) { const oldRow = clone(row) const newRow = Object.assign(clone(row), values) populateRow(oldRow, metadata) populateRow(newRow, metadata) await dispatch('updateMatchFilters', { view, row: oldRow, fields }) await dispatch('updateSearchMatchesForRow', { row: oldRow, fields }) await dispatch('updateMatchFilters', { view, row: newRow, fields }) await dispatch('updateSearchMatchesForRow', { row: newRow, fields }) const oldRowExists = oldRow._.matchFilters && oldRow._.matchSearch const newRowExists = newRow._.matchFilters && newRow._.matchSearch if (oldRowExists && !newRowExists) { await dispatch('deletedExistingRow', { view, fields, row }) } else if (!oldRowExists && newRowExists) { await dispatch('createdNewRow', { view, fields, values: newRow, metadata, }) } else if (oldRowExists && newRowExists) { // Instead of implementing a metadata updated mutation, we can easily just // call the deleted and created mutation because that will have the same effect. commit('UPDATE_GROUP_BY_METADATA_COUNT', { fields, registry: this.$registry, row: oldRow, increase: false, decrease: true, }) commit('UPDATE_GROUP_BY_METADATA_COUNT', { fields, registry: this.$registry, row: newRow, increase: true, decrease: false, }) // If the new order already exists in the buffer and is not the row that has // been updated, we need to decrease all the other orders, otherwise we could // have duplicate orders. if ( getters.getAllRows.findIndex( (r) => r.id !== newRow.id && r.order === newRow.order ) > -1 ) { commit('DECREASE_ORDERS_IN_BUFFER_LOWER_THAN', newRow.order) } // Figure out if the row is currently in the buffer. const sortFunction = getRowSortFunction( this.$registry, view.sortings, fields, view.group_bys ) const allRows = getters.getAllRows const index = allRows.findIndex((r) => r.id === row.id) const oldIsFirst = index === 0 const oldIsLast = index === allRows.length - 1 const oldRowInBuffer = (oldIsFirst && getters.getBufferStartIndex === 0) || (oldIsLast && getters.getBufferEndIndex === getters.getCount) || (index > 0 && index < allRows.length - 1) if (oldRowInBuffer) { // If the old row is inside the buffer at a known position. commit('UPDATE_ROW_IN_BUFFER', { row, values, metadata }) commit('SET_BUFFER_LIMIT', getters.getBufferLimit - 1) } else if (oldIsFirst) { // If the old row exists in the buffer, but is at the before position. commit('DELETE_ROW_IN_BUFFER_WITHOUT_UPDATE', row) commit('SET_BUFFER_LIMIT', getters.getBufferLimit - 1) } else if (oldIsLast) { // If the old row exists in the buffer, bit is at the after position. commit('DELETE_ROW_IN_BUFFER_WITHOUT_UPDATE', row) commit('SET_BUFFER_LIMIT', getters.getBufferLimit - 1) } else { // The row does not exist in the buffer, so we need to check if it is before // or after the buffer. const allRowsCopy = clone(getters.getAllRows) const oldRowIndex = allRowsCopy.findIndex((r) => r.id === oldRow.id) if (oldRowIndex > -1) { allRowsCopy.splice(oldRowIndex, 1) } allRowsCopy.push(oldRow) allRowsCopy.sort(sortFunction) const oldIndex = allRowsCopy.findIndex((r) => r.id === newRow.id) if (oldIndex === 0) { // If the old row is before the buffer. commit('SET_BUFFER_START_INDEX', getters.getBufferStartIndex - 1) } } // Calculate what the new index should be. const allRowsCopy = clone(getters.getAllRows) const oldRowIndex = allRowsCopy.findIndex((r) => r.id === oldRow.id) if (oldRowIndex > -1) { allRowsCopy.splice(oldRowIndex, 1) } allRowsCopy.push(newRow) allRowsCopy.sort(sortFunction) const newIndex = allRowsCopy.findIndex((r) => r.id === newRow.id) const newIsFirst = newIndex === 0 const newIsLast = newIndex === allRowsCopy.length - 1 const newRowInBuffer = (newIsFirst && getters.getBufferStartIndex === 0) || (newIsLast && getters.getBufferEndIndex === getters.getCount - 1) || (newIndex > 0 && newIndex < allRowsCopy.length - 1) if (oldRowInBuffer && newRowInBuffer) { // If the old row and the new row are in the buffer. if (index !== newIndex) { commit('MOVE_EXISTING_ROW_IN_BUFFER', { row: oldRow, index: newIndex, }) } commit('SET_BUFFER_LIMIT', getters.getBufferLimit + 1) } else if (newRowInBuffer) { // If the new row should be in the buffer, but wasn't. commit('INSERT_EXISTING_ROW_IN_BUFFER_AT_INDEX', { row: newRow, index: newIndex, }) commit('SET_BUFFER_LIMIT', getters.getBufferLimit + 1) } else if (newIsFirst) { // If the new row is before the buffer. commit('SET_BUFFER_START_INDEX', getters.getBufferStartIndex + 1) } // Remove every pending AI field if a value is provided for it. This will make // sure the loading state will stop if the value is updated. This is done even // if the row is not found in the buffer because it could have been removed from // the buffer when scrolling outside the buffer range. const updatedFieldIds = Object.entries(values) .filter( ([key, value]) => key.startsWith('field_') && !_.isEqual(value, oldRow[key]) ) .map(([key, value]) => parseInt(key.split('_')[1])) commit('CLEAR_PENDING_FIELD_OPERATIONS', { fieldIds: updatedFieldIds, rowId: row.id, }) // If the row as in the old buffer, but ended up at the first/before or // last/after position. This means that we can't know for sure the row should // be in the buffer, so it is removed from it. if (oldRowInBuffer && !newRowInBuffer && (newIsFirst || newIsLast)) { commit('DELETE_ROW_IN_BUFFER_WITHOUT_UPDATE', row) } await dispatch('correctMultiSelect') } }, /** * Called when the user wants to delete an existing row in the table. */ async deleteExistingRow( { commit, dispatch, getters }, { table, view, row, fields, getScrollTop } ) { commit('SET_ROW_LOADING', { row, value: true }) try { await RowService(this.$client).delete(table.id, row.id) await dispatch('deletedExistingRow', { view, fields, row, getScrollTop, }) await dispatch('fetchByScrollTopDelayed', { scrollTop: getScrollTop(), fields, }) dispatch('fetchAllFieldAggregationData', { view }) } catch (error) { commit('SET_ROW_LOADING', { row, value: false }) throw error } }, /** * Attempt to delete all multi-selected rows. */ async deleteSelectedRows( { dispatch, getters }, { table, view, fields, getScrollTop } ) { if (!getters.isMultiSelectActive) { return } let rows = [] if (getters.areMultiSelectRowsWithinBuffer) { rows = getters.getSelectedRows } else { // Rows not in buffer, fetch from backend const [minRowIndex, maxRowIndex] = getters.getMultiSelectRowIndexSorted const limit = maxRowIndex - minRowIndex + 1 rows = await dispatch('fetchRowsByIndex', { startIndex: minRowIndex, limit, includeFields: fields, }) } const rowIdsToDelete = rows.map((r) => r.id) await RowService(this.$client).batchDelete(table.id, rowIdsToDelete) for (const row of rows) { await dispatch('deletedExistingRow', { view, fields, row, getScrollTop, }) } dispatch('clearAndDisableMultiSelect', { view }) await dispatch('fetchByScrollTopDelayed', { scrollTop: getScrollTop(), fields, }) dispatch('fetchAllFieldAggregationData', { view }) }, /** * Called after an existing row has been deleted, which could be by the user or * via another channel. */ async deletedExistingRow( { commit, getters, dispatch }, { view, fields, row } ) { row = clone(row) populateRow(row) // Check if that row was visible in the view. await dispatch('updateMatchFilters', { view, row, fields }) await dispatch('updateSearchMatchesForRow', { row, fields }) // 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) { return } // Decrease the count in the group by metadata if an entry exists. commit('UPDATE_GROUP_BY_METADATA_COUNT', { fields, registry: this.$registry, row, increase: false, decrease: true, }) // Now that we know for sure that the row belongs in the view, we need to figure // out if is before, inside or after the buffered results. const allRowsCopy = clone(getters.getAllRows) const exists = allRowsCopy.findIndex((r) => r.id === row.id) > -1 // If the row is already in the buffer, it can be removed via the // `DELETE_ROW_IN_BUFFER` commit, which removes it and changes the buffer state // accordingly. if (exists) { commit('DELETE_ROW_IN_BUFFER', row) await dispatch('correctMultiSelect') return } // Otherwise we have to calculate was before or after the current buffer. allRowsCopy.push(row) const sortFunction = getRowSortFunction( this.$registry, view.sortings, fields, view.group_bys ) allRowsCopy.sort(sortFunction) const index = allRowsCopy.findIndex((r) => r.id === row.id) // If the row is at position 0, it means that the row existed before the buffer, // which means the buffer start index has decreased. if (index === 0) { commit('SET_BUFFER_START_INDEX', getters.getBufferStartIndex - 1) } // Regardless of where the commit('SET_COUNT', getters.getCount - 1) await dispatch('correctMultiSelect') }, /** * Triggered when a row has been changed, or has a pending change in the provided * overrides. */ onRowChange({ dispatch }, { view, row, fields, overrides = {} }) { dispatch('updateMatchFilters', { view, row, fields, overrides }) dispatch('updateMatchSortings', { view, row, fields, overrides }) dispatch('updateSearchMatchesForRow', { row, fields, overrides }) }, /** * Checks if the given row still matches the given view filters. The row's * matchFilters value is updated accordingly. It is also possible to provide some * override values that not actually belong to the row to do some preliminary checks. */ updateMatchFilters({ commit }, { view, row, fields, overrides = {} }) { const values = JSON.parse(JSON.stringify(row)) Object.assign(values, overrides) // The value is always valid if the filters are disabled. const matches = view.filters_disabled ? true : matchSearchFilters( this.$registry, view.filter_type, view.filters, view.filter_groups, fields, values ) commit('SET_ROW_MATCH_FILTERS', { row, value: matches }) }, /** * Changes the current search parameters if provided and optionally refreshes which * cells match the new search parameters by updating every rows row._.matchSearch and * row._.fieldSearchMatches attributes. */ updateSearch( { commit, dispatch, getters, state }, { fields, activeSearchTerm = state.activeSearchTerm, hideRowsNotMatchingSearch = state.hideRowsNotMatchingSearch, refreshMatchesOnClient = true, } ) { commit('SET_SEARCH', { activeSearchTerm, hideRowsNotMatchingSearch }) if (refreshMatchesOnClient) { getters.getAllRows.forEach((row) => dispatch('updateSearchMatchesForRow', { row, fields, forced: true, }) ) } }, /** * Updates a single row's row._.matchSearch and row._.fieldSearchMatches based on the * current search parameters and row data. Overrides can be provided which can be used * to override a row's field values when checking if they match the search parameters. */ updateSearchMatchesForRow( { commit, getters, rootGetters }, { row, fields = null, overrides, forced = false } ) { // Avoid computing search on table loading if (getters.getActiveSearchTerm || forced) { const rowSearchMatches = calculateSingleRowSearchMatches( row, getters.getActiveSearchTerm, getters.isHidingRowsNotMatchingSearch, fields, this.$registry, getDefaultSearchModeFromEnv(this.$config), overrides ) commit('SET_ROW_SEARCH_MATCHES', rowSearchMatches) } }, /** * Checks if the given row index is still the same. The row's matchSortings value is * updated accordingly. It is also possible to provide some override values that not * actually belong to the row to do some preliminary checks. */ updateMatchSortings( { commit, getters }, { view, row, fields, overrides = {} } ) { const values = clone(row) Object.assign(values, overrides) const allRows = getters.getAllRows const currentIndex = getters.getAllRows.findIndex((r) => r.id === row.id) const sortedRows = clone(allRows) sortedRows[currentIndex] = values sortedRows.sort( getRowSortFunction(this.$registry, view.sortings, fields, view.group_bys) ) const newIndex = sortedRows.findIndex((r) => r.id === row.id) commit('SET_ROW_MATCH_SORTINGS', { row, value: currentIndex === newIndex }) }, /** * Refreshes the row in the store if the given rowId exists. If the row * doesn't exist in the store, nothing will happen. This method ensures that * the row refreshed is the one of this store, because it could be that the * row object could come from another store. */ async refreshRowById( { dispatch, getters }, { grid, rowId, fields, getScrollTop, isRowOpenedInModal = false } ) { const row = getters.getRow(rowId) if (row === undefined) { return } await dispatch('refreshRow', { grid, row, fields, getScrollTop, isRowOpenedInModal, }) }, /** * The row is going to be removed or repositioned if the matchFilters and * matchSortings state is false. It will make the state correct. */ async refreshRow( { dispatch, commit }, { grid, row, fields, getScrollTop, isRowOpenedInModal = false } ) { const rowShouldBeHidden = !row._.matchFilters || !row._.matchSearch if ( row._.selectedBy.length === 0 && rowShouldBeHidden && !isRowOpenedInModal ) { commit('DELETE_ROW_IN_BUFFER', row) } else if (row._.selectedBy.length === 0 && !row._.matchSortings) { await dispatch('updatedExistingRow', { view: grid, fields, row, values: row, }) commit('SET_ROW_MATCH_SORTINGS', { row, value: true }) } dispatch('fetchByScrollTopDelayed', { scrollTop: getScrollTop(), fields, }) }, updateRowMetadata( { commit, getters, dispatch }, { tableId, rowId, rowMetadataType, updateFunction } ) { const row = getters.getRow(rowId) if (row) { commit('UPDATE_ROW_METADATA', { row, rowMetadataType, updateFunction }) } }, /** * Clears the values of all multi-selected cells by updating them to their null values. */ async clearValuesFromMultipleCellSelection( { getters, dispatch }, { table, view, allVisibleFields, allFieldsInTable, getScrollTop } ) { const [minFieldIndex, maxFieldIndex] = getters.getMultiSelectFieldIndexSorted const [minRowIndex, maxRowIndex] = getters.getMultiSelectRowIndexSorted const numberOfRowsSelected = maxRowIndex - minRowIndex + 1 const selectedFields = allVisibleFields.slice( minFieldIndex, maxFieldIndex + 1 ) // Get the empty value for each selected field const emptyValues = selectedFields.map((field) => this.$registry.get('field', field.type).getEmptyValue(field) ) // Copy the empty value array once for each row selected const data = [] for (let index = 0; index < numberOfRowsSelected; index++) { data.push(emptyValues) } await dispatch('updateDataIntoCells', { table, view, allVisibleFields, allFieldsInTable, getScrollTop, textData: data, rowIndex: minRowIndex, fieldIndex: minFieldIndex, }) }, /** * Add the fieldId to the list of pending field operations for the given rowIds. * This is used to show a loading spinner when a field is being updated. For example, * the AI field type uses this to show a spinner when the AI values are being * generated in a background task. */ setPendingFieldOperations({ commit }, { fieldId, rowIds, value = true }) { commit('SET_PENDING_FIELD_OPERATIONS', { fieldId, rowIds, value }) }, AIValuesGenerationError({ commit, dispatch }, { fieldId, rowIds }) { commit('SET_PENDING_FIELD_OPERATIONS', { fieldId, rowIds, value: false }) dispatch( 'toast/error', { title: this.$i18n.t('gridView.AIValuesGenerationErrorTitle'), message: this.$i18n.t('gridView.AIValuesGenerationErrorMessage'), }, { root: true } ) }, setRowHeight({ commit, dispatch }, value) { commit('UPDATE_ROW_HEIGHT', value) }, } export const getters = { isLoaded(state) { return state.loaded }, getLastGridId(state) { return state.lastGridId }, getCount(state) { return state.count }, getRowHeight(state) { return state.rowHeight }, getRowsTop(state) { return state.rowsTop }, getRowsLength(state) { return state.rows.length }, getPlaceholderHeight(state) { return state.count * state.rowHeight }, getRowPadding(state) { return state.rowPadding }, getAllRows(state) { return state.rows }, getRow: (state) => (id) => { return state.rows.find((row) => row.id === id) }, getRows(state) { return state.rows.slice(state.rowsStartIndex, state.rowsEndIndex) }, getRowsStartIndex(state) { return state.rowsStartIndex }, getRowsEndIndex(state) { return state.rowsEndIndex }, getBufferRequestSize(state) { return state.bufferRequestSize }, getBufferStartIndex(state) { return state.bufferStartIndex }, getBufferEndIndex(state) { return state.bufferStartIndex + state.bufferLimit }, getBufferLimit(state) { return state.bufferLimit }, getScrollTop(state) { return state.scrollTop }, getWindowHeight(state) { return state.windowHeight }, getAllFieldOptions(state) { return state.fieldOptions }, getOrderedFieldOptions: (state, getters) => (fields) => { const primaryField = fields.find((f) => f.primary === true) const primaryFieldId = primaryField?.id || -1 return Object.entries(getters.getAllFieldOptions) .map(([fieldIdStr, options]) => [parseInt(fieldIdStr), options]) .sort(([a, { order: orderA }], [b, { order: orderB }]) => { const isAPrimary = a === primaryFieldId const isBPrimary = b === primaryFieldId // Place primary field first if (isAPrimary === true && !isBPrimary) { return -1 } else if (isBPrimary === true && !isAPrimary) { return 1 } // Then by order if (orderA > orderB) { return 1 } else if (orderA < orderB) { return -1 } // Finally by id if order is the same return a - b }) }, getOrderedVisibleFieldOptions: (state, getters) => (fields) => { return getters .getOrderedFieldOptions(fields) .filter(([fieldId, options]) => options.hidden === false) }, getNumberOfVisibleFields(state) { return Object.values(state.fieldOptions).filter((fo) => fo.hidden === false) .length }, isFirst: (state) => (id) => { const index = state.rows.findIndex((row) => row.id === id) return index === 0 }, isLast: (state) => (id) => { const index = state.rows.findIndex((row) => row.id === id) return index === state.rows.length - 1 }, getAddRowHover(state) { return state.addRowHover }, getActiveSearchTerm(state) { return state.activeSearchTerm }, isHidingRowsNotMatchingSearch(state) { return state.hideRowsNotMatchingSearch }, getServerSearchTerm(state) { return state.hideRowsNotMatchingSearch ? state.activeSearchTerm : false }, getHighestOrder(state) { let order = new BigNumber('0.00000000000000000000') state.rows.forEach((r) => { const rOrder = new BigNumber(r.order) if (rOrder.isGreaterThan(order)) { order = rOrder } }) return order }, isMultiSelectActive(state) { return state.multiSelectActive }, isMultiSelectHolding(state) { return state.multiSelectHolding }, getMultiSelectRowIndexSorted(state) { return [ Math.min(state.multiSelectHeadRowIndex, state.multiSelectTailRowIndex), Math.max(state.multiSelectHeadRowIndex, state.multiSelectTailRowIndex), ] }, getMultiSelectFieldIndexSorted(state) { return [ Math.min( state.multiSelectHeadFieldIndex, state.multiSelectTailFieldIndex ), Math.max( state.multiSelectHeadFieldIndex, state.multiSelectTailFieldIndex ), ] }, getMultiSelectHeadFieldIndex(state) { return state.multiSelectHeadFieldIndex }, getMultiSelectTailFieldIndex(state) { return state.multiSelectTailFieldIndex }, getMultiSelectHeadRowIndex(state) { return state.multiSelectHeadRowIndex }, getMultiSelectTailRowIndex(state) { return state.multiSelectTailRowIndex }, getMultiSelectStartRowIndex(state) { return state.multiSelectStartRowIndex }, getMultiSelectStartFieldIndex(state) { return state.multiSelectStartFieldIndex }, // Get the index of a row given it's row id. // This will calculate the row index from the current buffer position and offset. getRowIndexById: (state, getters) => (rowId) => { const bufferIndex = state.rows.findIndex((r) => r.id === rowId) if (bufferIndex !== -1) { return getters.getBufferStartIndex + bufferIndex } return -1 }, getRowIdByIndex: (state, getters) => (rowIndex) => { const row = state.rows[rowIndex - getters.getBufferStartIndex] if (row) { return row.id } return -1 }, getFieldIdByIndex: (state, getters) => (fieldIndex, fields) => { const orderedFieldOptions = getters.getOrderedVisibleFieldOptions(fields) if (orderedFieldOptions[fieldIndex]) { return orderedFieldOptions[fieldIndex][0] } return -1 }, // Check if all the multi-select rows are within the row buffer areMultiSelectRowsWithinBuffer(state, getters) { const [minRow, maxRow] = getters.getMultiSelectRowIndexSorted return ( minRow >= getters.getBufferStartIndex && maxRow <= getters.getBufferEndIndex ) }, // Return all rows within a multi-select grid if they are within the current row buffer getSelectedRows(state, getters) { const [minRow, maxRow] = getters.getMultiSelectRowIndexSorted if (getters.areMultiSelectRowsWithinBuffer) { return state.rows.slice( minRow - state.bufferStartIndex, maxRow - state.bufferStartIndex + 1 ) } }, getSelectedFields: (state, getters) => (fields) => { const [minField, maxField] = getters.getMultiSelectFieldIndexSorted const selectedFields = [] const fieldMap = fields.reduce((acc, field) => { acc[field.id] = field return acc }, {}) for (let i = minField; i <= maxField; i++) { const fieldId = getters.getFieldIdByIndex(i, fields) if (fieldId !== -1) { selectedFields.push(fieldMap[fieldId]) } } return selectedFields }, getAllFieldAggregationData(state) { return state.fieldAggregationData }, hasSelectedCell(state) { return state.rows.some((row) => { return row._.selected && row._.selectedFieldId !== -1 }) }, getActiveGroupBys(state) { return state.activeGroupBys }, getGroupByMetadata(state) { return state.groupByMetadata }, getAdhocFiltering(state) { return state.adhocFiltering }, getAdhocSorting(state) { return state.adhocSorting }, hasPendingFieldOps: (state) => (fieldId, rowId) => { const key = getPendingOperationKey(fieldId, rowId) return state.pendingFieldOps[key] !== undefined }, } export default { namespaced: true, state, getters, actions, mutations, }