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,
}