import Vue from 'vue' import axios from 'axios' import { RefreshCancelledError } from '@baserow/modules/core/errors' import { clone } from '@baserow/modules/core/utils/object' import { GroupTaskQueue } from '@baserow/modules/core/utils/queue' import { calculateSingleRowSearchMatches, extractRowMetadata, getFilters, getOrderBy, getRowSortFunction, matchSearchFilters, } from '@baserow/modules/database/utils/view' import RowService from '@baserow/modules/database/services/row' import { extractRowReadOnlyValues, prepareNewOldAndUpdateRequestValues, prepareRowForRequest, } from '@baserow/modules/database/utils/row' import { getDefaultSearchModeFromEnv } from '@baserow/modules/database/utils/search' /** * This view store mixin can be used to efficiently keep and maintain the rows of a * table/view without fetching all the rows at once. It first fetches an initial set * of rows and creates an array in the state containing these rows and adds a `null` * object for every un-fetched row. * * It can correctly handle when new rows are created, updated or deleted without * needing every row in the state. Components that make use of this store mixin * just have to dispatch an action telling which rows are currently visible and the * store handles the rest. Rows that are un-fetched, so when they are `null`, must * be shown in a loading state if the user is looking at them. * * Example of how the state of rows could look: * * ``` * rows = [ * { id: 1, order: '1.00000000000000000000', field_1: 'Name' }, * { id: 2, order: '2.00000000000000000000', field_1: 'Name' }, * { id: 3, order: '3.00000000000000000000', field_1: 'Name' }, * { id: 4, order: '4.00000000000000000000', field_1: 'Name' }, * null, * null, * null, * null, * { id: 9, order: '10.00000000000000000000', field_1: 'Name' }, * { id: 10, order: '10.00000000000000000000', field_1: 'Name' }, * null, * null * ] * ``` */ export default ({ service, customPopulateRow }) => { let lastRequestController = null const updateRowQueue = new GroupTaskQueue() const populateRow = (row, metadata = {}) => { if (customPopulateRow) { customPopulateRow(row) } if (row._ == null) { row._ = { metadata, } } // Matching rows for front-end only search is not yet properly // supported and tested in this store mixin. Only server-side search // implementation is finished. row._.matchSearch = true row._.fieldSearchMatches = [] return row } /** * This helper function calculates the most optimal `limit` `offset` range of rows * that must be fetched. Based on the provided visible `startIndex` and `endIndex` * we know which rows must be fetched because those values are `null` in the * provided `rows` array. If a request must be made, we want to do so in the most * efficient manner, so we want to respect the ideal request size by filling up * the request with other rows before and after the range that must also be fetched. * This function checks if there are other `null` rows close to the range and if * so, it tries to include them in the range. * * @param rows An array containing the rows that we have fetched already. * @param requestSize The ideal request when making a request to the server. * @param startIndex The start index of the visible rows. * @param endIndex The end index of the visible rows. */ const getRangeToFetch = (rows, requestSize, startIndex, endIndex) => { const visibleRows = rows.slice(startIndex, endIndex + 1) const firstNullIndex = visibleRows.findIndex((row) => row === null) const lastNullIndex = visibleRows.lastIndexOf(null) // If all of the visible rows have been fetched, so none of them are `null`, we // don't have to do anything. if (firstNullIndex === -1) { return } // Figure out what the request size is. In almost all cases this is going to // be the configured request size, but it could be that more rows must be visible // and in that case we want to increase it const maxRequestSize = Math.max(startIndex - endIndex, requestSize) // The initial offset can be the first `null` found in the range. let offset = startIndex + firstNullIndex let limit = lastNullIndex - firstNullIndex + 1 // Because we have an ideal request size and this is often higher than the // visible rows, we want to efficiently fetch additional rows that are close // to the visible range. while (limit < maxRequestSize) { const previous = rows[offset - 1] const next = rows[offset + limit + 1] // If both the previous and next item are not `null`, which means there is // no un-fetched row before or after the range anymore, we want to stop the for // loop because there is nothing to fetch. if (previous !== null && next !== null) { break } if (previous === null) { offset -= 1 limit += 1 } if (next === null) { limit += 1 } } // The `limit` could exceed the `maxRequestSize` if it's an odd number because it // checks if there is an un-fetched row before and after in one loop. return { offset, limit: Math.min(limit, maxRequestSize) } } const state = () => ({ // If another visible rows action has been dispatched whilst a previous action // is still fetching rows, the new action is temporarily delayed and its // parameters are stored here. delayedRequest: null, // Holds the last requested start and end index of the currently visible rows visibleRange: { startIndex: 0, endIndex: 0, }, // The ideal number of rows to fetch when making a request. requestSize: 100, // The current view id. viewId: -1, // If true, ad hoc filtering is used instead of persistent one adhocFiltering: false, // If true, ad hoc sorting is used adhocSorting: false, // Indicates whether the store is currently fetching another batch of rows. fetching: false, // A list of all the rows in the table. The ones that haven't been fetched yet // are `null`. rows: [], // The row that's in dragging state and is being moved to another position. draggingRow: null, // The row that the dragging row was before when the dragging state was started. // This is needed to revert the position if anything goes wrong or the escape // key was pressed. draggingOriginalBefore: null, activeSearchTerm: '', }) const mutations = { SET_DELAYED_REQUEST(state, delayedRequestParameters) { state.delayedRequest = delayedRequestParameters }, SET_VISIBLE(state, { startIndex, endIndex }) { state.visibleRange.startIndex = startIndex state.visibleRange.endIndex = endIndex }, SET_VIEW_ID(state, viewId) { state.viewId = viewId }, SET_ROWS(state, rows) { Vue.set(state, 'rows', rows) }, SET_FETCHING(state, value) { state.fetching = value }, UPDATE_ROWS(state, { offset, rows }) { for (let i = 0; i < rows.length; i++) { const rowStoreIndex = i + offset const rowInStore = state.rows[rowStoreIndex] const row = rows[i] if (rowInStore === undefined || rowInStore === null) { // If the row doesn't yet exist in the store, we can populate the provided // row and set it. state.rows.splice(rowStoreIndex, 1, populateRow(row)) } else { // If the row does exist in the store, we can extend it with the provided // data of the provided row so the state won't be lost. Object.assign(state.rows[rowStoreIndex], row) } } }, INSERT_ROW_AT_INDEX(state, { index, row }) { state.rows.splice(index, 0, row) }, DELETE_ROW_AT_INDEX(state, { index }) { state.rows.splice(index, 1) }, MOVE_ROW(state, { oldIndex, newIndex }) { state.rows.splice(newIndex, 0, state.rows.splice(oldIndex, 1)[0]) }, UPDATE_ROW(state, { row, values }) { const index = state.rows.findIndex( (item) => item !== null && item.id === row.id ) if (index !== -1) { Object.assign(state.rows[index], values) } else { Object.assign(row, values) } }, UPDATE_ROW_AT_INDEX(state, { index, values }) { Object.assign(state.rows[index], values) }, UPDATE_ROW_VALUES(state, { row, values }) { Object.assign(row, values) }, ADD_FIELD_TO_ALL_ROWS(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 (row !== null && !Object.prototype.hasOwnProperty.call(row, name)) { row[`field_${field.id}`] = value return { ...row } } return row }) }, START_ROW_DRAG(state, { index }) { state.rows[index]._.dragging = true state.draggingRow = state.rows[index] state.draggingOriginalBefore = state.rows[index + 1] || null }, STOP_ROW_DRAG(state, { index }) { state.rows[index]._.dragging = false state.draggingRow = null state.draggingOriginalBefore = null }, SET_SEARCH(state, { activeSearchTerm }) { state.activeSearchTerm = activeSearchTerm }, 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 }, 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) } }, SET_ADHOC_FILTERING(state, adhocFiltering) { state.adhocFiltering = adhocFiltering }, SET_ADHOC_SORTING(state, adhocSorting) { state.adhocSorting = adhocSorting }, } const actions = { /** * Set the view id for the view. */ setViewId({ commit }, { viewId }) { commit('SET_VIEW_ID', viewId) }, /** * This action fetches the initial set of rows via the provided service. After * that it will fill the state with the newly fetched rows and the rest will be * un-fetched `null` objects. */ async fetchInitialRows( context, { viewId, fields, adhocFiltering, adhocSorting, initialRowArguments = {} } ) { const { commit, getters, rootGetters } = context commit('SET_VIEW_ID', viewId) commit('SET_SEARCH', { activeSearchTerm: '', }) commit('SET_ADHOC_FILTERING', adhocFiltering) commit('SET_ADHOC_SORTING', adhocSorting) const view = rootGetters['view/get'](viewId) const { data } = await service(this.$client).fetchRows({ viewId, offset: 0, limit: getters.getRequestSize, search: getters.getServerSearchTerm, searchMode: getDefaultSearchModeFromEnv(this.$config), publicUrl: rootGetters['page/view/public/getIsPublic'], publicAuthToken: rootGetters['page/view/public/getAuthToken'], orderBy: getOrderBy(view, adhocSorting), filters: getFilters(view, adhocFiltering), ...initialRowArguments, }) const rows = Array(data.count).fill(null) data.results.forEach((row, index) => { const metadata = extractRowMetadata(data, row.id) rows[index] = populateRow(row, metadata) }) commit('SET_ROWS', rows) return data }, /** * Should be called when the different rows are displayed to the user. This * could for example happen when a user scrolls. It will figure out which rows * have not been fetched and will make a request with the backend to replace to * missing ones if needed. */ async fetchMissingRowsInNewRange( { dispatch, getters, commit, rootGetters }, parameters ) { const { startIndex, endIndex } = parameters // If the store is already fetching a set of pages, we're temporarily storing // the parameters so that this action can be dispatched again with the latest // parameters. if (getters.getFetching) { commit('SET_DELAYED_REQUEST', parameters) return } // Check if the currently visible range isn't to same as the provided one // because we don't want to do anything in that case. const currentVisible = getters.getVisibleRange if ( currentVisible.startIndex === startIndex && currentVisible.endIndex === endIndex ) { return } // Update the last visible range to make sure this action isn't dispatched // multiple times. commit('SET_VISIBLE', { startIndex, endIndex }) // Check what the ideal range is to fetch with the backend. const rangeToFetch = getRangeToFetch( getters.getRows, getters.getRequestSize, startIndex, endIndex ) // If there is no ideal range or if the limit is 0, then there aren't any rows // to fetch so we can stop. if (rangeToFetch === undefined || rangeToFetch.limit === 0) { return } const view = rootGetters['view/get'](getters.getViewId) // We can only make one request at the same time, so we're going to set the // fetching state to `true` to prevent multiple requests being fired // simultaneously. commit('SET_FETCHING', true) lastRequestController = new AbortController() try { const { data } = await service(this.$client).fetchRows({ viewId: getters.getViewId, offset: rangeToFetch.offset, limit: rangeToFetch.limit, signal: lastRequestController.signal, search: getters.getServerSearchTerm, searchMode: getDefaultSearchModeFromEnv(this.$config), publicUrl: rootGetters['page/view/public/getIsPublic'], publicAuthToken: rootGetters['page/view/public/getAuthToken'], orderBy: getOrderBy(view, getters.getAdhocSorting), filters: getFilters(view, getters.getAdhocFiltering), }) commit('UPDATE_ROWS', { offset: rangeToFetch.offset, rows: data.results, }) } catch (error) { if (axios.isCancel(error)) { throw new RefreshCancelledError() } else { lastRequestController = null throw error } } finally { // Check if another `fetchMissingRowsInNewRange` action has been dispatched // while we were fetching the rows. If so, we need to dispatch the same // action again with the latest parameters. commit('SET_FETCHING', false) const delayedRequestParameters = getters.getDelayedRequest if (delayedRequestParameters !== null) { commit('SET_DELAYED_REQUEST', null) await dispatch('fetchMissingRowsInNewRange', delayedRequestParameters) } } }, /** * Refreshes the row buffer by clearing all of the rows in the store and * re-fetching the currently visible rows. This is typically done when a filter has * changed and we can't trust what's in the store anymore. */ async refresh( { dispatch, commit, getters, rootGetters }, { fields, adhocFiltering, adhocSorting, includeFieldOptions = false } ) { commit('SET_ADHOC_FILTERING', adhocFiltering) commit('SET_ADHOC_SORTING', adhocSorting) // If another refresh or fetch request is currently running, we need to cancel // it because the response is most likely going to be outdated and we don't // need it anymore. if (lastRequestController !== null) { lastRequestController.abort() } lastRequestController = new AbortController() const view = rootGetters['view/get'](getters.getViewId) try { // We first need to fetch the count of all rows because we need to know how // many rows there are in total to estimate what are new visible range it // going to be. commit('SET_FETCHING', true) const { data: { count }, } = await service(this.$client).fetchCount({ viewId: getters.getViewId, signal: lastRequestController.signal, search: getters.getServerSearchTerm, searchMode: getDefaultSearchModeFromEnv(this.$config), publicUrl: rootGetters['page/view/public/getIsPublic'], publicAuthToken: rootGetters['page/view/public/getAuthToken'], filters: getFilters(view, adhocFiltering), }) // Create a new empty array containing un-fetched rows. const rows = Array(count).fill(null) let startIndex = 0 let endIndex = 0 if (count > 0) { // Figure out which range was previous visible and see if that still fits // within the new set of rows. Otherwise we're going to fall const currentVisible = getters.getVisibleRange startIndex = currentVisible.startIndex endIndex = currentVisible.endIndex const difference = count - endIndex if (difference < 0) { startIndex += difference startIndex = startIndex >= 0 ? startIndex : 0 endIndex += difference } // Based on the newly calculated range we can figure out which rows we want // to fetch from the backend to populate our store with. These should be the // rows that the user is going to look at. const rangeToFetch = getRangeToFetch( rows, getters.getRequestSize, startIndex, endIndex ) // Only fetch visible rows if there are any. const { data: { results }, } = await service(this.$client).fetchRows({ viewId: getters.getViewId, offset: rangeToFetch.offset, limit: rangeToFetch.limit, includeFieldOptions, signal: lastRequestController.signal, search: getters.getServerSearchTerm, searchMode: getDefaultSearchModeFromEnv(this.$config), publicUrl: rootGetters['page/view/public/getIsPublic'], publicAuthToken: rootGetters['page/view/public/getAuthToken'], orderBy: getOrderBy(view, adhocSorting), filters: getFilters(view, adhocFiltering), }) results.forEach((row, index) => { rows[rangeToFetch.offset + index] = populateRow(row) }) } commit('SET_ROWS', rows) commit('SET_VISIBLE', { startIndex, endIndex }) } catch (error) { if (axios.isCancel(error)) { throw new RefreshCancelledError() } else { lastRequestController = null throw error } } finally { commit('SET_FETCHING', false) } }, /** * Adds a field with a provided value to the rows in the store. This will for * example be called when a new field has been created. */ addField({ commit }, { field, value = null }) { commit('ADD_FIELD_TO_ALL_ROWS', { field, value }) }, /** * Check if the provided row matches the provided view filters. */ rowMatchesFilters(context, { view, fields, row, overrides = {} }) { const values = JSON.parse(JSON.stringify(row)) Object.assign(values, overrides) // The value is always valid if the filters are disabled. return view.filters_disabled ? true : matchSearchFilters( this.$registry, view.filter_type, view.filters, view.filter_groups, fields, values ) }, /** * Returns the index that the provided row was supposed to have if it was in the * store. Because some rows haven't been fetched from the backend, we need to * figure out which `null` object could have been the row in the store. */ findIndexOfNotExistingRow({ getters }, { view, fields, row }) { const sortFunction = getRowSortFunction( this.$registry, view.sortings, fields ) const allRows = getters.getRows let index = allRows.findIndex((existingRow) => { return existingRow !== null && sortFunction(row, existingRow) < 0 }) let isCertain = true if (index === -1 && allRows[allRows.length - 1] !== null) { // If we don't know where to position the new row and the last row is null, we // can safely assume it's the last row because when finding the index we // only check if the new row is before an existing row. index = allRows.length } else if (index === -1) { // If we don't know where to position the new row we can assume near the // end, but we're not sure where exactly. Because of that we'll add it as // null to the end. index = allRows.length isCertain = false } else if (allRows[index - 1] === null) { // If the row must inserted at the beginning of a known chunk of fetched // rows, we can't know for sure it actually has to be inserted directly before. // In that case, we will insert it as null. isCertain = false } return { index, isCertain } }, /** * Returns the index of a row that's in the store. This also works if the row * hasn't been fetched yet, it will then point to the `null` object representing * the row. */ findIndexOfExistingRow({ dispatch, getters }, { view, fields, row }) { const sortFunction = getRowSortFunction( this.$registry, view.sortings, fields ) const allRows = getters.getRows let index = allRows.findIndex((existingRow) => { return existingRow !== null && existingRow.id === row.id }) let isCertain = true if (index === -1) { // If the row is not found in the index, we will have to figure out what the // position could be. index = allRows.findIndex((existingRow) => { return existingRow !== null && sortFunction(row, existingRow) < 0 }) isCertain = false if (index === -1 && allRows[allRows.length - 1] === null) { // If we don't know where to position the existing row and the last row is // null, we can safely assume it's the last row because when finding the // index we only check if the new row is before an existing row. index = allRows.length } if (index >= 0) { index -= 1 } } return { index, isCertain } }, /** * 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', { row, values: data }) }, /** * Creates a new row and adds it to the store if needed. */ async createNewRow( { dispatch, commit, getters }, { view, table, fields, values } ) { const preparedRow = prepareRowForRequest(values, fields, this.$registry) const { data } = await RowService(this.$client).create( table.id, preparedRow ) return await dispatch('afterNewRowCreated', { view, fields, values: data, }) }, /** * When a new row is created and it doesn't yet in exist in this store, so we must * insert it in the right position. Based on the values of the row we can * calculate if the row should be added (matches filters) and at which position * (sortings). * * Because we only fetch the rows from the backend that are actually needed, it * could be that we can't figure out where the row should be inserted. In that * case, we add a `null` in the area that is unknown. The store * already has other null values for rows that are un-fetched. So the un-fetched * row representations that are `null` in the array will be fetched automatically * when the user wants to see them. */ async afterNewRowCreated( { dispatch, getters, commit }, { view, fields, values } ) { let row = clone(values) populateRow(row) const rowMatchesFilters = await dispatch('rowMatchesFilters', { view, fields, row, }) await dispatch('updateSearchMatchesForRow', { view, fields, row, }) if (!rowMatchesFilters || !row._.matchSearch) { return } const { index, isCertain } = await dispatch('findIndexOfNotExistingRow', { view, fields, row, }) // If we're not completely certain about the target index of the new row, we // must add it as `null` to the store because then it will automatically be // fetched when the user looks at it. if (!isCertain) { row = null } commit('INSERT_ROW_AT_INDEX', { index, row }) }, /** * Updates a row with the prepared values and updates the store accordingly. * Prepared values are `newRowValues`, `oldRowValues` and `updateRequestValues` that * are prepared by the `prepareNewOldAndUpdateRequestValues` function. */ async updatePreparedRowValues( { commit, dispatch }, { table, view, row, fields, values, oldValues, updateRequestValues } ) { await dispatch('afterExistingRowUpdated', { 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 `afterExistingRowUpdated` 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 }, }) 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 updateRowQueue.add(async () => { const { data } = await RowService(this.$client).update( table.id, row.id, updateRequestValues ) const readOnlyData = extractRowReadOnlyValues( data, fields, this.$registry ) commit('UPDATE_ROW', { row, values: readOnlyData }) }, row.id) } catch (error) { dispatch('afterExistingRowUpdated', { view, fields, row, values: oldValues, }) commit('UPDATE_ROW_VALUES', { row, values: { ...oldValues }, }) throw error } }, /** * Updates the value of a row and make the updates to the store accordingly. */ async updateRowValue( { dispatch }, { table, view, row, field, fields, value, oldValue } ) { const { newRowValues, oldRowValues, updateRequestValues } = prepareNewOldAndUpdateRequestValues( row, fields, field, value, oldValue, this.$registry ) await dispatch('updatePreparedRowValues', { table, view, row, fields, values: newRowValues, oldValues: oldRowValues, updateRequestValues, }) }, /** * Prepares the values of multiple row fields and returns the new and old values * that can be used to update the store and the values that can be used to update * the row in the backend. */ prepareMultipleRowValues(context, { row, fields, values, oldValues }) { let preparedValues = {} let preparedOldValues = {} let updateRequestValues = {} Object.entries(values).forEach(([fieldId, value]) => { const oldValue = oldValues[fieldId] const field = fields.find((f) => parseInt(f.id) === parseInt(fieldId)) const { newRowValues, oldRowValues, updateRequestValues: requestValues, } = prepareNewOldAndUpdateRequestValues( row, fields, field, value, oldValue, this.$registry ) preparedValues = { ...preparedValues, ...newRowValues } preparedOldValues = { ...preparedOldValues, ...oldRowValues } updateRequestValues = { ...updateRequestValues, ...requestValues } }) return { preparedValues, preparedOldValues, updateRequestValues } }, /** * Updates the values of multiple row fields and make the updates to the store accordingly. */ async updateRowValues( { dispatch }, { table, view, row, fields, values, oldValues } ) { const { preparedValues, preparedOldValues, updateRequestValues } = await dispatch('prepareMultipleRowValues', { table, view, row, fields, values, oldValues, }) await dispatch('updatePreparedRowValues', { table, view, row, fields, values: preparedValues, oldValues: preparedOldValues, updateRequestValues, }) }, /** * When an existing row is updated, the state in the store must also be updated. * Because we always receive the old and new state we can calculate if the row * already existed in store. If it does exist, but the row was not fetched yet, * so in a `null` state, we can still figure out what the index was supposed to * be and take action on that. * * It works very similar to what happens when a row is created. If we can be * sure about the new position then we can update the and keep it's data. If we * can't be 100% sure, the row will be updated as `null`. */ async afterExistingRowUpdated( { dispatch, commit }, { view, fields, row, values } ) { const oldRow = clone(row) let newRow = Object.assign(clone(row), values) populateRow(oldRow) populateRow(newRow) const oldMatchesFilters = await dispatch('rowMatchesFilters', { view, fields, row: oldRow, }) const newMatchesFilters = await dispatch('rowMatchesFilters', { view, fields, row: newRow, }) await dispatch('updateSearchMatchesForRow', { view, fields, row: oldRow, }) await dispatch('updateSearchMatchesForRow', { view, fields, row: newRow, }) const oldRowMatches = oldMatchesFilters && oldRow._.matchSearch const newRowMatches = newMatchesFilters && newRow._.matchSearch if (oldRowMatches && !newRowMatches) { // If the old row exists in the buffer, we must update that one with the // values, even though it's going to be deleted, because the row object // could be used by the row edit modal, who needs to have the latest change // to it, to keep it in sync. const { index: oldIndex, isCertain: oldIsCertain } = await dispatch( 'findIndexOfExistingRow', { view, fields, row: oldRow, } ) if (oldIsCertain) { commit('UPDATE_ROW_AT_INDEX', { index: oldIndex, values }) } // If the old row did match the filters, but after the update it does not // anymore, we can safely remove it from the store. await dispatch('afterExistingRowDeleted', { view, fields, row: oldRow, }) } else if (!oldRowMatches && newRowMatches) { // If the old row didn't match filters, but the updated one does, we need to // add it to the store. await dispatch('afterNewRowCreated', { view, fields, values: newRow, }) } else if (oldRowMatches && newRowMatches) { // If the old and updated row already exists in the store, we need to update it. const { index: oldIndex, isCertain: oldIsCertain } = await dispatch( 'findIndexOfExistingRow', { view, fields, row: oldRow, } ) const findNewRow = await dispatch('findIndexOfNotExistingRow', { view, fields, row: newRow, }) let { index: newIndex } = findNewRow const { isCertain: newIsCertain } = findNewRow // When finding the new index, the old row still existed in the store. When // the newIndex is higher than the old index, we need to compensate for this // because when figuring out the new position, we expected the existing row // not to be there. if (newIndex > oldIndex) { newIndex -= 1 } if (oldIsCertain && newIsCertain) { // If both the old and updated are certain, we can just update the values // of the row so the original row, including the state, will persist. commit('UPDATE_ROW_AT_INDEX', { index: oldIndex, values }) if (oldIndex !== newIndex) { // If the index has changed we want to move the row. We're moving it and // not recreating it because we want the state to persist. commit('MOVE_ROW', { oldIndex, newIndex }) } } else { // If either the old and updated row is not certain, which means it's in a // `null` state, there is no row to persist to we can easily recreate it // at the right position. if (!newIsCertain) { newRow = null } commit('DELETE_ROW_AT_INDEX', { index: oldIndex }) commit('INSERT_ROW_AT_INDEX', { index: newIndex, row: newRow }) } } }, /** * When a new row deleted and it does exist in the store, it must be deleted * removed from is. Based on the provided values of the row we can figure out if * it was in the store and we can figure out what index it has. */ async afterExistingRowDeleted({ dispatch, commit }, { view, fields, row }) { row = clone(row) populateRow(row) const rowMatchesFilters = await dispatch('rowMatchesFilters', { view, fields, row, }) await dispatch('updateSearchMatchesForRow', { view, fields, row, }) if (!rowMatchesFilters || !row._.matchSearch) { return } const { index } = await dispatch('findIndexOfExistingRow', { view, fields, row, }) if (index > -1) { commit('DELETE_ROW_AT_INDEX', { index }) } }, /** * Brings the provided row in a dragging state so that it can freely moved to * another position. */ startRowDrag({ commit, getters }, { row }) { const rows = getters.getRows const index = rows.findIndex((r) => r !== null && r.id === row.id) commit('START_ROW_DRAG', { index }) }, /** * This action stops the dragging state of a row, will figure out which values * need to updated and will make a call to the backend. If something goes wrong, * the row is moved back to the position. */ async stopRowDrag({ dispatch, commit, getters }, { table, view, fields }) { const row = getters.getDraggingRow if (row === null) { return } const rows = getters.getRows const index = rows.findIndex((r) => r !== null && r.id === row.id) const before = rows[index + 1] || null const originalBefore = getters.getDraggingOriginalBefore commit('STOP_ROW_DRAG', { index }) if (originalBefore !== before) { try { const { data } = await RowService(this.$client).move( table.id, row.id, before !== null ? before.id : null ) commit('UPDATE_ROW', { row, values: data }) } catch (error) { dispatch('cancelRowDrag', { view, fields, row, stop: false }) throw error } } }, /** * Cancels the current row drag action by reverting back to the original position * while respecting any new rows that have been moved into there in the mean time. */ cancelRowDrag( { dispatch, getters, commit }, { view, fields, row, stop = true } ) { if (stop) { const rows = getters.getRows const index = rows.findIndex((r) => r !== null && r.id === row.id) commit('STOP_ROW_DRAG', { index }) } dispatch('afterExistingRowUpdated', { view, fields, row, values: row, }) }, /** * Moves the provided existing row to the position of the target row. * * @param row The row object that must be moved. * @param targetRow Will be placed before or after the provided row. */ forceMoveRowBefore({ getters, commit }, { row, targetRow }) { const rows = getters.getRows const newIndex = rows.findIndex( (r) => r !== null && r.id === targetRow.id ) if (newIndex > -1) { const oldIndex = rows.findIndex((r) => r !== null && r.id === row.id) commit('MOVE_ROW', { oldIndex, newIndex }) return true } return false }, /** * 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, refreshMatchesOnClient = true, } ) { commit('SET_SEARCH', { activeSearchTerm }) if (refreshMatchesOnClient) { getters.getRows.forEach((row) => { if (row !== null) { 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, 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) } }, /** * Updates a single row's row._.metadata based on the provided rowMetadataType and * updateFunction. */ updateRowMetadata( { commit, getters }, { rowId, rowMetadataType, updateFunction } ) { const row = getters.getRow(rowId) if (row) { commit('UPDATE_ROW_METADATA', { row, rowMetadataType, updateFunction }) } }, } const getters = { getViewId(state) { return state.viewId }, getDelayedRequest(state) { return state.delayedRequest }, getVisibleRange(state) { return state.visibleRange }, getRequestSize(state) { return state.requestSize }, getFetching(state) { return state.fetching }, getRow: (state) => (id) => { return state.rows.find((row) => row.id === id) }, getRows(state) { return state.rows }, getDraggingRow(state) { return state.draggingRow }, getDraggingOriginalBefore(state) { return state.draggingOriginalBefore }, getActiveSearchTerm(state) { return state.activeSearchTerm }, getServerSearchTerm(state) { return state.activeSearchTerm }, isHidingRowsNotMatchingSearch(state) { return true }, getAdhocFiltering(state) { return state.adhocFiltering }, getAdhocSorting(state) { return state.adhocSorting }, } return { namespaced: true, state, getters, actions, mutations, } }