Search code examples
vue.jspinia

How to use Pinia with a Map of table configuration objects


I am using Pinia to manage the state of different DataTables throughout my application. It's possible for multiple tables to be opened "on top" of each other (i.e. in overlay(s) over each other). This is why I am reworking our current setup where only 1 table can be opened and another table would overwrite the previous table, to a new system where multiple tables can be opened at the same time.

I thought of using Pinia with a Map() to keep all my data table configurations in place, and make use of a "activeDataTableName" field to keep track of which table is currently the 'active' one.

My Pinia store looks like this:

export const useDataTableStore = defineStore('data-table', {
  state: () => ({
    dataTables: new Map(), // use a Map to store all data table configurations
    activeDataTableName: null, // Used to track which is the current active data table
  }),
  getters: {
    getActiveDataTableConfig: (state) => {
      const dtConfig = state.dataTables.get(state.activeDataTableName)
      return dtConfig || null
    },
    getDataTableName: (state) => state.activeDataTableName,
    getLoading: (state) => {
      const dtConfig = state.dataTables.get(state.activeDataTableName)
      return dtConfig ? dtConfig.loading : true
    },
    getDataTableColumns: (state) => {
      const dtConfig = state.dataTables.get(state.activeDataTableName)
      return dtConfig ? dtConfig.dataTableColumns : []
    },
    getTotalRecords: (state) => {
      const dtConfig = state.dataTables.get(state.activeDataTableName)
      return dtConfig ? dtConfig.totalRecords : 0
    },
    getFirst: (state) => {
      const dtConfig = state.dataTables.get(state.activeDataTableName)
      return dtConfig ? dtConfig.first : 0
    },
    getAmountRowsToShow: (state) => {
      const dtConfig = state.dataTables.get(state.activeDataTableName)
      return dtConfig ? dtConfig.amountRowsToShow : 15
    },
    getSelectedRows: (state) => {
      const dtConfig = state.dataTables.get(state.activeDataTableName)
      return dtConfig ? dtConfig.selectedRows : []
    },
    getFocussedRow: (state) => {
      const dtConfig = state.dataTables.get(state.activeDataTableName)
      return dtConfig ? dtConfig.focussedRow : null
    },
    getLazyParams: (state) => {
      const dtConfig = state.dataTables.get(state.activeDataTableName)
      return dtConfig ? dtConfig.lazyParams: {}
    }
    getSelectAll: (state) => {
      const dtConfig = state.dataTables.get(state.activeDataTableName)
      return dtConfig ? dtConfig.selectAll : false
    },
  },
  actions: {
    async initialize (nuxtApp, dtName) {
      // The initialize function sets the dataTableName and loads the columns
      const newDataTableConfigObject = {
        loading: true,
        dataTableName: dtName,
        dataTableColumns: [],
        totalRecords: 0,
        first: 0,
        amountRowsToShow: 15,
        selectedRows: [],
        focussedRow: null,
        lazyParams: {},
        selectAll: false,
      }
      this.dataTables.set(dtName, newDataTableConfigObject)
      this.activeDataTableName = dtName
    },
    setSelectedRows (rows) {
      const dtConfig = this.dataTables.get(this.activeDataTableName)
      if (dtConfig) {
        dtConfig.selectedRows = rows
      }
    },
  },
})

My problems arise when I am trying to do the 2-way binding in using v-model. Eg: i'll have a component "DataTable" which takes in the values from my store in a v-model as so :

<DataTable
        ref="dataTableRef"
        v-model:selection="store.selectedRows"
        v-model:first="store.first"
        v-model:rows="store.amountRowsToShow"
        ...
/>

when my store is initialised:

const store = useDataTableStore()

the values of "selectedRows", "first" etc. will not be directly in the store, but rather in the active configuration object inside dataTables in the store. I'm also struggling when trying to set values to the store's variables:

store.amountRowsToShow = rows
store.lazyParams.first = store.getFirst
store.lazyParams.rows = store.getAmountRowsToShow

this can probably be solved with specific setters for each of them, but if I have to create a setter method for each potential object inside lazyParams, it becomes very tedious. Isn't there a better approach to this?


UPDATE: I changed the store as per recommendation of @Estus Flask. It looks like this now :

import { defineStore, acceptHMRUpdate } from 'pinia'
import {
  returnDataTableDefaultColumnsFullInfo,
  returnDataTableColumnsFullInfoForFields,
  returnDataTableAllColumns,
} from 'give-a-day-shared/data/dataTableConfig.mjs'
import { ResponseError } from 'give-a-day-shared/routes/responseUtils.mjs'
import { isSuperAuthor } from '../functions/frontendAuthUtils.js'

function createEmptyDataTableConfig (dtName) {
  return {
    showDataTable: false,
    loading: true,
    dataTableName: dtName,
    allDataTableColumns: [],
    dataTableColumns: [],
    dataVisibilitySelectedOption: null,
    dataVisibilityPlatformOptionVisible: null,
    dataVisibilityUmbrellaOptionVisible: null,
    totalRecords: 0,
    first: 0,
    amountRowsToShow: 15,
    selectedRows: [],
    focussedRow: null,
    selectAll: false,
    lazyParams: {}, // This variable is used to send to the backend
    previewPanelVisible: true,
  }
}

function setNestedProperty (obj, path, value) {
  const keys = path.split('.')
  let current = obj

  for (let i = 0; i < keys.length - 1; i++) {
    if (!current[keys[i]]) {
      current[keys[i]] = {}
    }
    current = current[keys[i]]
  }

  current[keys[keys.length - 1]] = value
}

export const useDataTableStore = defineStore('data-table', {
  state: () => ({
    dataTables: new Map(), // use a Map to store all data table configurations
  }),
  getters: {
    // BIG NOTE: Parameterized getters are NOT cached.
    // This is why we create helpers that create computed's that use these getters to get the caching back.
    getShowDataTable: (state) => (dtName) => state.dataTables.get(dtName)?.showDataTable || false,
    getLoading: (state) => (dtName) => state.dataTables.get(dtName)?.loading || true,
    getAllDataTableColumns: (state) => (dtName) => state.dataTables.get(dtName)?.allDataTableColumns || [],
    getDataTableColumns: (state) => (dtName) => state.dataTables.get(dtName)?.dataTableColumns || [],
    getDataVisibilitySelectedOption: (state) => (dtName) => state.dataTables.get(dtName)?.dataVisibilitySelectedOption || null,
    getDataVisibilityPlatformOptionVisible: (state) => (dtName) => state.dataTables.get(dtName)?.dataVisibilityPlatformOptionVisible || null,
    getDataVisibilityUmbrellaOptionVisible: (state) => (dtName) => state.dataTables.get(dtName)?.dataVisibilityUmbrellaOptionVisible || null,
    getTotalRecords: (state) => (dtName) => state.dataTables.get(dtName)?.totalRecords || 0,
    getFirst: (state) => (dtName) => state.dataTables.get(dtName)?.first || 0,
    getAmountRowsToShow: (state) => (dtName) => state.dataTables.get(dtName)?.amountRowsToShow || 15,
    getSelectedRows: (state) => (dtName) => state.dataTables.get(dtName)?.selectedRows || [],
    getFocussedRow: (state) => (dtName) => state.dataTables.get(dtName)?.focussedRow || null,
    getSelectAll: (state) => (dtName) => state.dataTables.get(dtName)?.selectAll || false,
    getLazyParams: (state) => (dtName) => state.dataTables.get(dtName)?.lazyParams || {},
    getPreviewPanelVisible: (state) => (dtName) => state.dataTables.get(dtName)?.previewPanelVisible || true,
  },
  actions: {
    async initialize (nuxtApp, dtName) {
      // The initialize function sets the dataTableConfig and loads the columns
      this.resetConfig(dtName)
      await this.fetchDataTableColumns(nuxtApp, dtName)
    },
    resetConfig (dtName) {
      const newConfig = {
        showDataTable: false,
        loading: true,
        dataTableName: dtName,
        allDataTableColumns: [],
        dataTableColumns: [],
        dataVisibilitySelectedOption: null,
        dataVisibilityPlatformOptionVisible: null,
        dataVisibilityUmbrellaOptionVisible: null,
        totalRecords: 0,
        first: 0,
        amountRowsToShow: 15,
        selectedRows: [],
        focussedRow: null,
        selectAll: false,
        lazyParams: {}, // This variable is used to send to the backend
        previewPanelVisible: true,
      }
      this.dataTables.set(dtName, newConfig)
    },
    async fetchInitialDataToShowState (nuxtApp, dtName, hideDataToShowOptionPlatform = false, hideDataToShowOptionUmbrella = false) {
      if (!isSuperAuthor(nuxtApp.$auth.user)) {
        let dataTableConfig = this.dataTables.get(dtName)
        if (!dataTableConfig) {
          throw new ResponseError('no_data_table_config_found')
        }

        if (nuxtApp.$getPlatformOfUser() && !hideDataToShowOptionPlatform) {
          let platform = await nuxtApp.$getFromBackend('/fetchPlatformById', { platformId: nuxtApp.$getPlatformOfUser().id })
          if (platform) {
            dataTableConfig.dataVisibilityPlatformOptionVisible = true
            dataTableConfig.dataVisibilitySelectedOption = 'show_all'
          }
        }

        if (nuxtApp.$globalState.toggledOrganisationId && !hideDataToShowOptionUmbrella) {
          let umbrellaOrgIds = await nuxtApp.$getFromBackend('/fetchAllOrganisationIdsInTree', { organisationId: nuxtApp.$globalState.toggledOrganisationId, includeInvites: false })
          if (umbrellaOrgIds.length > 1) {
            dataTableConfig.dataVisibilityUmbrellaOptionVisible = true
            if (!dataTableConfig.dataVisibilitySelectedOption) {
              dataTableConfig.dataVisibilitySelectedOption = 'show_only_umbrella'
            }
          }
        }

        if (!dataTableConfig.dataVisibilitySelectedOption) {
          dataTableConfig.dataVisibilitySelectedOption = 'show_only_myself'
        }
      }
    },
    async fetchDataTableColumns (nuxtApp, dtName) {
      let dataTableConfig = this.dataTables.get(dtName)
      if (!dataTableConfig) {
        throw new ResponseError('no_data_table_config_found')
      }
      let allTableColumns = returnDataTableAllColumns(nuxtApp, dtName) // we use this for mapping the filter query params
      if (allTableColumns) {
        dataTableConfig.allDataTableColumns = allTableColumns
      }
      let existingTableOrgColumnsConfig = await nuxtApp.$getFromBackend('/fetchDataTableColumnConfiguration', { dataTableName: dataTableConfig.dataTableName })
      if (existingTableOrgColumnsConfig) {
        // Use the custom config of the organisation
        let columnData = existingTableOrgColumnsConfig.columnData
        if (typeof columnData === 'string') {
          columnData = JSON.parse(columnData)
        }
        let columnsFullInfo = returnDataTableColumnsFullInfoForFields(nuxtApp, dataTableConfig.dataTableName, columnData)
        dataTableConfig.dataTableColumns = columnsFullInfo
      } else {
        // Use the default config
        let defaultColumnsFullInfo = returnDataTableDefaultColumnsFullInfo(nuxtApp, dataTableConfig.dataTableName)
        dataTableConfig.dataTableColumns = defaultColumnsFullInfo
      }
    },
    setLoading (dtName, bool) {
      const dtConfig = this.dataTables.get(dtName)
      if (dtConfig) {
        dtConfig.loading = bool
      }
    },
    setShowDataTable (dtName, bool) {
      const dtConfig = this.dataTables.get(dtName)
      if (dtConfig) {
        dtConfig.showDataTable = bool
      }
    },
    setSelectedRows (dtName, rows) {
      const dtConfig = this.dataTables.get(dtName)
      if (dtConfig) {
        dtConfig.selectedRows = rows
      }
    },
    setFirst (dtName, first) {
      const dtConfig = this.dataTables.get(dtName)
      if (dtConfig) {
        dtConfig.first = first
      }
    },
    setAmountRowsToShow (dtName, amountRowsToShow) {
      const dtConfig = this.dataTables.get(dtName)
      if (dtConfig) {
        dtConfig.amountRowsToShow = amountRowsToShow
      }
    },
    setTotalRecords (dtName, amount) {
      const dtConfig = this.dataTables.get(dtName)
      if (dtConfig) {
        dtConfig.totalRecords = amount
      }
    },
    setDataVisibilitySelectedOption (dtName, dataToShow) {
      const dtConfig = this.dataTables.get(dtName)
      if (dtConfig) {
        dtConfig.dataVisibilitySelectedOption = dataToShow
      }
    },
    setSelectAll (dtName, bool) {
      const dtConfig = this.dataTables.get(dtName)
      if (dtConfig) {
        dtConfig.selectAll = bool
      }
    },
    setFocussedRow (dtName, row) {
      const dtConfig = this.dataTables.get(dtName)
      if (dtConfig) {
        dtConfig.focussedRow = row
      }
    },
    setLazyParams (dtName, lazyParams) {
      const dtConfig = this.dataTables.get(dtName)
      if (dtConfig) {
        dtConfig.lazyParams = lazyParams
      }
    },
    setLazyParamsProperty (dtName, propertyPath, value) {
      // The propertyPath is just a string value (with a . if it's a nested property) (eg: 'filters.organisationName')
      const dtConfig = this.dataTables.get(dtName)
      if (dtConfig) {
        setNestedProperty(dtConfig.lazyParams, propertyPath, value)
      }
    },
  },
})

if (import.meta.hot) {
  import.meta.hot.accept(acceptHMRUpdate(useDataTableStore, import.meta.hot))
}

And when the Pinia store is loaded into my component:

const dtStore = useDataTableStore()

I am getting the following error:

[nuxt] [request error] [unhandled] [500] Converting circular structure to JSON
    --> starting at object with constructor 'Object'
    |     property '_context' -> object with constructor 'Object'
    --- property 'app' closes the circle
  --> starting at object with constructor 'Object'
  at JSON.stringify (<anonymous>)

This seems to only happen when hard refeshing the page (F5) and not when just doing a regular navigation to the page.


Solution

  • It's ok to have activeDataTableName in the store, although this state may belong to a component that is "active" rather than the store.

    There is a fundamental problem with getters like getActiveDataTableConfig. If multiple tables are supposed to be rendered at once, it would make impossible to use getters to show data in inactive tables. It would potentially cause less problems if all tables were treated as equals in the store. Parametrized computeds can be used for this:

    getters: {
      getSelectedRows: (state) => {
        return (name) => state.dataTables.get(name) || null;
      },
      ...
    

    Parametrized computeds aren't cached, this could be critical for performance-heavy usage, which a table could be. A helper that creates computed for specific table could be used instead or in addition to parametrized computeds:

    function mapStoreTable(name) {
      const store = useTableStore();
      const tableData = store.dataTables.get(name);
      
      const selectedRows = computed(() => store.getSelectedRows(name));
      ...
      return { selectedRows, ... };
    }
    

    This will make selectedRows, etc to not react to the changes in other tables.

    The update action can accept table name to update a specific table:

      actions: {
        setSelectedRows(name, rows) {
          const dtConfig = this.dataTables.get(name);
          if (dtConfig) {
            dtConfig.selectedRows = rows
          }
        },
        ...
    

    It's generally a doubtful practice to mutate a store directly with v-model. Although this is allowed in Pinia, this was previously discouraged in Vuex because this results in code that is difficult to debug and maintain. This would be impossible with the suggested helper any way. v-model can be desugared to a prop and an event. Or a writable computed that dispatches an action on set and can be used as v-model:selection="selectedRowsModel" can be created:

    const { selectedRows } = mapStoreTable(tableName);
    
    const selectedRowsModel = computed({
      get() {
        return selectedRows.value;
      },
      set(rows) {
        store.setSelectedRows(tableName, rows) 
      }
    });