Source

store/search_module/search_module.js

/**
 * @namespace Stores
 * @category Stores
 * @module search-module-store
 * */

import moment from 'moment'
import Vue from 'vue'
import flushPromises from 'flush-promises'
import { getQueryStringValue } from '@/utils/querystring'
import { normalizeCategory } from '@/services/entities/location-main-search-item'
import vehicleService from '@/services/vehicle-service'
import driverService from '@/services/driver-service'
import circuitService from '@/services/circuit-service'
import { getNestedValue } from '@/utils/object.js'

const shouldLog =
  (getQueryStringValue('verbose') || '').includes('1') ||
  (getQueryStringValue('verbose') || '').includes('search')

const R = require('ramda')

const storage = (Vue.$localStorage &&
  Vue.$localStorage.fromNamespace('search_module')) || {
  getItem() {},
  setItem() {},
}

const stateKeys = {
  vehicle: 'selectedVehiclesIds',
  driver: 'selectedDriversIds',
  circuit: 'selectedCircuitsIds',
}

/**
 * @TODO refactor/move into service/utils
 */
function pushIfMissing(state, listName, value) {
  if (state[listName].indexOf(value) === -1) {
    state[listName].push(value)
  }
}

/**
 * @TODO refactor/move into service/utils
 */
function spliceIfFound(state, listName, value) {
  if (state[listName].indexOf(value) !== -1) {
    state[listName].splice(state[listName].indexOf(value), 1)
  }
}

/**
 * @TODO refactor/move into service/utils
 */
function spliceRangeIfFound(state, listName, range) {
  let index = -1
  state[listName].forEach((currRange, currIndex) => {
    if (currRange[0] == range[0] && currRange[1] == range[1]) {
      index = currIndex
    }
  })
  state[listName].splice(index, 1)
}

function newState() {
  return {
    /**
     * We keep stats from last search selection to be able to show searchDaysCount after selection is cleared
     * see: searchDaysCount
     */
    lastSelectionMetadata: {
      searchDaysCount: 0,
      selectedVehiclesIdsCount: [],
      selectedDriversIdsCount: [],
      selectedCircuitsIdsCount: [],
    },
    initialized: false,
    /**
     * timestamp updated when the user click the "Valider" button
     */
    validatedAt: 0,
    /**
     * Reference data and current selection
     */
    vehicleCategories: [],
    vehicles: [],
    selectedVehiclesIds: [],
    driverCategories: [],
    drivers: [],
    selectedDriversIds: [],
    circuitCategories: [],
    circuits: [],
    selectedCircuitsIds: [],
    selectedDateRanges: [],
    /**
     * Used for location main search (see: premier_dernier_jour request parameter)
     */
    isFirstAndLastDay: true,
    /**
     * Is data first fetch?
     * @TODO refactor/remove if unused
     */
    vehiclesFirstFetch: false,
    driversFirstFetch: false,
    circuitsFirstFetch: false,
    /**
     * A flag to know whenever to show a back arrow  to switch back to results view (Form mode)
     */
    hasResults: false,
    /**
     * The current selected tab in form mode (vehicle/driver/circuit)
     */
    activeSearchFormTabName: '',
    /**
     * Will populate with the current mode (free_search/advanced_search)
     * free_search: floating search on top of map
     * advanced_search: legacy search form (like georedv2)
     */
    mode: '',
    /**
     * Flag to show a loader icon inside the "Valider" button
     */
    isSearchInProgress: false,
    /**
     * Current component view (selection/results)
     */
    view: 'selection',
  }
}

export default {
  namespaced: true,
  state: newState(),
  getters: {
    //Used by SearchResults (Location module) to compute total text
    searchDaysCount: (state) => state.lastSelectionMetadata.searchDaysCount,
    activeSearchFormTabSelectedItemsCountFromLastSearch(state, getters) {
      return (
        state.lastSelectionMetadata[
          stateKeys[getters.activeSearchFormTabName || 'vehicle'] + 'Count'
        ] || 0
      )
    },
    /** */
    currentMode: (state) => state.mode,
    isCurrentTabVehicles(state, getters) {
      return getters['activeSearchFormTabName'] === 'vehicle'
    },
    isCurrentTabAnyOf(state, getters) {
      return (arr) => arr.includes(getters['activeSearchFormTabName'])
    },
    isCurrentTabCircuit(state, getters) {
      return getters['activeSearchFormTabName'] === 'circuit'
    },
    activeSearchFormTabName(state) {
      return state.activeSearchFormTabName || 'vehicle'
    },
    isInitialLoadingComplete(state) {
      return {
        vehicles: state.vehiclesFirstFetch,
        drivers: state.driversFirstFetch,
        circuits: state.circuitsFirstFetch,
      }
    },
    isValidSelection(state) {
      return (
        state.selectedVehiclesIds.length > 0 ||
        state.selectedDriversIds.length > 0 ||
        state.selectedCircuitsIds.length > 0
      )
    },
    hasResults(state) {
      return state.hasResults
    },
    getSelectedIdsByType(state) {
      return (type) => state[stateKeys[type]]
    },
    isItemSelected: (state, getters) => (type, id) =>
      getters['getSelectedIdsByType'](type).findIndex((_id) => _id == id) >= 0,
    getSelectionLength: (state) =>
      (state.selectedVehiclesIds.length +
        state.selectedDriversIds.length +
        state.selectedCircuitsIds.length) *
      (state.selectedDateRanges.length || 1),
    getSelection: (state) => ({
      selectedVehiclesIds: state.selectedVehiclesIds,
      selectedDriversIds: state.selectedDriversIds,
      selectedCircuitsIds: state.selectedCircuitsIds,
      selectedDateRanges: state.selectedDateRanges,
    }),
    /**
     * @todo Refactor/Remove if multiple selections, this is prone to errors
     * @param {*} state
     * @returns
     */
    getSelectionType(state) {
      if (state.selectedVehiclesIds.length > 0) {
        return 'vehicle'
      }
      if (state.selectedDriversIds.length > 0) {
        return 'driver'
      }
      if (state.selectedCircuitsIds.length > 0) {
        return 'circuit'
      }
    },
    getSelectionAsAString: (state) => {
      let vehicleList = state.vehicles
        .filter((v) => state.selectedVehiclesIds.indexOf(v.id) !== -1)
        .map((v) => v.name)
      if (vehicleList.length > 0) {
        vehicleList = JSON.stringify(vehicleList.map((v) => v).join(','))
      } else {
        vehicleList = ''
      }

      let driversList = state.drivers
        .filter((d) => state.selectedDriversIds.indexOf(d.id) !== -1)
        .map((d) => d.name)
      if (driversList.length > 0) {
        driversList = JSON.stringify(driversList.map((d) => d).join(','))
      } else {
        driversList = ''
      }

      let circuitsList = state.circuits
        .filter((c) => state.selectedCircuitsIds.indexOf(c.id) !== -1)
        .map((c) => c.name)

      if (circuitsList.length > 0) {
        circuitsList = JSON.stringify(circuitsList.map((d) => d).join(','))
      } else {
        circuitsList = ''
      }

      let dates = ''
      if (state.selectedDateRanges.length > 0) {
        state.selectedDateRanges.forEach((date) => {
          dates += JSON.stringify(date)
        })
      }

      let searchList = [vehicleList, driversList, circuitsList, dates]
        .map((val) => val)
        .join(',')

      return JSON.stringify(searchList)
    },
    hasSelectedDates: (state) => state.selectedDateRanges.length > 0,
    /**
     * Used to decide whenever to show a cancel button
     * @param {*} state
     * @returns
     */
    hasSelection: (state) =>
      state.selectedVehiclesIds.length > 0.0 ||
      state.selectedDriversIds.length > 0.0 ||
      state.selectedCircuitsIds.length > 0.0 ||
      state.selectedDateRanges.length > 0.0,
    getValidatedTimestamp: (state) => state.validatedAt,
    getVehicleCategories(state) {
      return state.vehicleCategories
    },
    getVehicles(state) {
      return state.vehicles
    },
    getVehicleById: (state) => (vehicleId) =>
      state.vehicles.find((v) => v.id == vehicleId),
    getSelectedVehicles(state) {
      return state.vehicles.filter(
        (v) => state.selectedVehiclesIds.indexOf(v.id) !== -1
      )
    },
    getDriverCategories(state) {
      return state.driverCategories
    },
    getDrivers(state) {
      return state.drivers
    },
    getSelectedDrivers(state) {
      return state.drivers.filter(
        (v) => state.selectedDriversIds.indexOf(v.id) !== -1
      )
    },
    getCircuitCategories(state) {
      return state.circuitCategories
    },
    getCircuits(state) {
      return state.circuits
    },
    getSelectedCircuits(state) {
      return state.circuits.filter(
        (v) => state.selectedCircuitsIds.indexOf(v.id) !== -1
      )
    },
    getSelectedDateRanges(state) {
      return state.selectedDateRanges
    },
  },
  mutations: {
    setIsFirstAndLastDay(state, value) {
      state.isFirstAndLastDay = value
    },
    setMode(state, mode) {
      state.mode = mode || 'advanced_search'
    },
    selectAll(state, type) {
      state[stateKeys[type]] = state[`${type}s`].map((item) => item.id)
    },
    activeSearchFormTabName(state, activeSearchFormTabName) {
      state.activeSearchFormTabName = activeSearchFormTabName
    },
    setFirstFetch(state, { type, value }) {
      state[
        {
          vehicle: 'vehiclesFirstFetch',
          driver: 'driversFirstFetch',
          circuit: 'circuitsFirstFetch',
        }[type]
      ] = value
    },
    initializeState(state) {
      Object.assign(state, newState())
      //console.log('initializeState')
    },
    setInitialized(state, value) {
      state.initialized = value
    },
    setVehicleCategories(state, value) {
      state.vehicleCategories = value
    },
    setVehicles(state, value = []) {
      state.vehicles = value
    },
    selectVehicle(state, id) {
      pushIfMissing(state, 'selectedVehiclesIds', id)
    },
    deselectVehicle(state, id) {
      spliceIfFound(state, 'selectedVehiclesIds', id)
    },
    setDrivers(state, value = []) {
      state.drivers = value
    },
    setDriverCategories(state, value) {
      state.driverCategories = value
    },
    selectDriver(state, id) {
      pushIfMissing(state, 'selectedDriversIds', id)
    },
    deselectDriver(state, id) {
      spliceIfFound(state, 'selectedDriversIds', id)
    },
    setCircuits(state, value = []) {
      state.circuits = value
    },
    setCircuitCategories(state, value) {
      state.circuitCategories = value
    },
    selectCircuit(state, id) {
      pushIfMissing(state, 'selectedCircuitsIds', id)
    },
    deselectCircuit(state, id) {
      spliceIfFound(state, 'selectedCircuitsIds', id)
    },
    /**
     * @TODO: Consider the end time set in the date picker form.
     */
    selectDateRange(state, range) {
      if (!(range instanceof Array) || range.length !== 2) {
        throw Error('INVALID_DATE_RANGE')
      }

      range[1] = moment(range[1])._d

      //splice data ranges who collide with this range
      state.selectedDateRanges
        .filter((currRange) => {
          //case 1: currRange inside new range
          //case 2: new range inside currRange
          return (
            (moment(currRange[0]).isSameOrAfter(range[0]) &&
              moment(currRange[1]).isSameOrBefore(range[1])) ||
            (moment(range[0]).isSameOrAfter(currRange[0]) &&
              moment(range[1]).isSameOrBefore(currRange[1]))
          )
        })
        .forEach((r) => spliceRangeIfFound(state, 'selectedDateRanges', r))

      state.selectedDateRanges.push(range)

      //sort by
      state.selectedDateRanges = state.selectedDateRanges.sort((a, b) => {
        return moment(a[0]).isBefore(moment(b[0])) ? -1 : 1
      })

      console.log('selectDateRange', state.selectedDateRanges)
    },
    deselectDateRange(state, range) {
      spliceRangeIfFound(state, 'selectedDateRanges', range)
    },
    updateLastSelectionMetadata(state) {
      state.lastSelectionMetadata.searchDaysCount =
        state.selectedDateRanges.length
      state.lastSelectionMetadata.selectedVehiclesIdsCount =
        state.selectedVehiclesIds.length
      state.lastSelectionMetadata.selectedDriversIdsCount =
        state.selectedDriversIds.length
      state.lastSelectionMetadata.selectedCircuitsIdsCount =
        state.selectedCircuitsIds.length
    },
    validateSearch(state) {
      state.validatedAt = Date.now()
    },
    clearDateSelection(state) {
      state.selectedDateRanges = []
      console.log('clearDateSelection')
    },
    clearSelection(state, type = null) {
      if (type === null) {
        state.selectedVehiclesIds = []
        state.selectedDriversIds = []
        state.selectedCircuitsIds = []
        state.selectedDateRanges = []
        //console.log('clearSelection::all')
      } else {
        state[stateKeys[type]] = []
        //console.log('clearSelection')
      }
    },
    setHasResults(state, value) {
      state.hasResults = value
      state.view = 'results'
    },
    toggleItems(state, { value, ids, type }) {
      if (type === 'date_range') {
        return
      }

      let copy = [].concat(state[stateKeys[type]])
      let id = 0
      for (let x in ids) {
        id = ids[x]
        if (value) {
          if (copy.indexOf(id) === -1) {
            copy.push(id)
          }
        } else {
          if (copy.indexOf(id) !== -1) {
            copy.splice(copy.indexOf(id), 1)
          }
        }
      }
      state[stateKeys[type]] = copy
    },
    restoreStateFromCache(state, cachedState) {
      //Localforage treat Date object as string
      cachedState.selectedDateRanges = cachedState.selectedDateRanges.map(
        (range) => {
          return [new Date(range[0]), new Date(range[1])]
        }
      )
      Object.assign(state, cachedState)
      shouldLog && Vue.$log.debug('restoreStateFromCache')
    },
  },
  actions: {
    /**
     * Update state flag
     */
    activeSearchFormTabName({ commit }, activeSearchFormTabName) {
      commit('activeSearchFormTabName', activeSearchFormTabName)
    },
    /**
     * Toggle selection (multiple)
     */
    toggleItems({ state, commit }, { type, value, ids }) {
      if (
        state.activeSearchFormTabName != type &&
        ['vehicle', 'driver', 'circuit'].includes(type)
      ) {
        commit('activeSearchFormTabName', type)
      }

      commit('toggleItems', {
        type,
        value,
        ids,
      })
    },
    resetStore({ commit }) {
      //commit("setInitialized", false); //Re-initialize reference data as well? (Not sure)
      commit('initializeState')
      //console.log('searchModule::resetStore')
    },
    /**
     * Called once from SearchModule component (self-responsability)
     * Called from location_module store (Location module requires search module data)
     * Called from LocationModule each time layout updates (to set mode)
     * @returns
     */
    async initialize({ dispatch, commit, state, rootGetters }, options = {}) {
      //wait for initialization in case was already called
      await flushPromises()

      if (!state.initialized) {
        //Try to load state from cache
        let cachedState = null

        /**
         * @TODO: Temporally disabled (Load last search selection after full refresh)
        try{
           cachedState = (await storage.getItem(`state_${rootGetters["auth/loginNameClientIdEncoded"]}`))||null
        }catch(err){
          Vue.$log.warn('cachedState',err.stack)
        } 
         */

        if (!cachedState) {
          commit('initializeState')
          commit('setInitialized', true)
          shouldLog && Vue.$log.debug('search_module', 'initializing')
        } else {
          shouldLog &&
            Vue.$log.debug('search_module', 'initializing from cache')
          commit('restoreStateFromCache', cachedState)
          commit('updateLastSelectionMetadata')
        }

        await dispatch('fetchVehicles')
        await dispatch('fetchDrivers')
        await dispatch('fetchCircuits')
      }

      if (options.mode) {
        commit('setMode', options.mode)
      }
    },
    /**
     * Set state flag
     */
    setHasResults({ commit }, value) {
      console.debug('setHasResults', value)
      commit('setHasResults', value)
    },
    /**
     * Set state flag
     */
    setIsFirstAndLastDay({ commit }, value) {
      commit('setIsFirstAndLastDay', value)
    },
    /**
     * Same as reset store?
     * @TODO refactor/remove if unused
     */
    clearSelection({ commit }, type) {
      commit('clearSelection', type)
    },
    clearDateSelection({ commit }) {
      commit('clearDateSelection')
    },
    /**
     * Triggered when the user click "Valider" or Search action button
     * - Updates timestamp in state
     */
    validateSearch({ commit, state, rootGetters }) {
      commit('updateLastSelectionMetadata')
      commit('validateSearch')

      //Save current selection in local cache
      storage.setItem(
        `state_${rootGetters['auth/loginNameClientIdEncoded']}`,
        R.omit(
          [
            'drivers',
            'driverCategories',
            'vehicles',
            'vehicleCategories',
            'circuits',
            'circuitCategories',
          ],
          JSON.parse(JSON.stringify(state))
        )
      )
    },
    /**
     * Select single item (vehicle/driver/circuit)
     */
    selectItem({ commit, state }, { value, type }) {
      const mutations = {
        vehicle: 'selectVehicle',
        driver: 'selectDriver',
        circuit: 'selectCircuit',
        date_range: 'selectDateRange',
      }

      //SearchBar: Switch the form tab depending the selected item
      if (
        state.activeSearchFormTabName != type &&
        ['vehicle', 'driver', 'circuit'].includes(type)
      ) {
        commit('activeSearchFormTabName', type)
      }

      commit(mutations[type], value)
      console.log('selectItem', {
        mutationName: mutations[type],
        value,
      })
    },
    deselectItem({ commit }, { value, type }) {
      const mutations = {
        vehicle: 'deselectVehicle',
        driver: 'deselectDriver',
        circuit: 'deselectCircuit',
        date_range: 'deselectDateRange',
      }
      commit(mutations[type], value)
    },
    async fetchVehicles({ commit, state }) {
      commit(
        'setVehicleCategories',
        await vehicleService.fetchVehicleCategories(normalizeCategory)
      )

      let items = await vehicleService.fetchVehicles(
        vehicleService.normalizeVehicle
      )
      let ids = items.map((i) => i.id)
      items = items.filter((i, ii) => ids.indexOf(i.id) == ii)
      commit('setVehicles', items)
      commit('setFirstFetch', {
        type: 'vehicle',
        value: true,
      })
    },
    async fetchDrivers({ commit, state }) {
      commit(
        'setDriverCategories',
        await driverService.fetchDriverCategories(normalizeCategory)
      )

      let response = await driverService.fetchDrivers()
      commit(
        'setDrivers',
        response &&
          response.map((i) => {
            let categoryId = null
            //No category = null (Bug?)
            if (!i._links.category.href) {
              //tries to set "sans category" category
              categoryId = (
                state.driverCategories.find(
                  (c) => c.name.toLowerCase().indexOf('sans ') !== -1
                ) || {}
              ).id
            } else {
              categoryId = i._links.category.href.match(/\d+/)[0]
            }

            let result = Object.freeze({
              id: i.id,
              name: i.name,
              categoryId: categoryId,
              //Bug: Sometimes, category name fail to compute
              categoryName:
                state.driverCategories.find((dc) => dc.id == categoryId)
                  ?.name || categoryId,
              //original: i
            })

            return result
          })
      )
      commit('setFirstFetch', {
        type: 'driver',
        value: true,
      })
    },
    async fetchCircuits({ commit, state }) {
      commit(
        'setCircuitCategories',
        await circuitService.fetchCircuitCategories((c) => {
          return normalizeCategory(c)
        })
      )

      let response = await circuitService.fetchCircuits()

      let circuits = []
      if (response) {
        response.forEach((i) => {
          if (i.archive == false) {
            let categoryId = null
            if (!i._links?.category?.href) {
              categoryId = (
                state.circuitCategories.find(
                  (c) => c.name.toLowerCase().indexOf('sans ') !== -1
                ) || {}
              ).id
            } else {
              categoryId = i._links.category.href.match(/\d+/)[0]
            }

            circuits.push(
              Object.freeze({
                id: getNestedValue(i, ['id', '_links.self.href'], null, {
                  transform(id, originalValue, rawItem) {
                    if ((id || '').toString().includes('/')) {
                      console.warn(
                        `Attribute id (Circuit Id) wasn't found in the APIV3 response (Circuits). Computed from _links.self.href (Intermediate solution)`
                      )
                      id = id.split('/').pop()
                    }

                    if (!id) {
                      console.error(
                        'No id found (Circuits) in APIV3 response',
                        {
                          rawItem,
                        }
                      )
                    }

                    return id
                  },
                }),
                name: i.shortName,
                categoryId: categoryId,
                categoryName: i.categoryName,
              })
            )
          }
        })
      }
      commit('setCircuits', circuits)
      commit('setFirstFetch', {
        type: 'circuit',
        value: true,
      })
    },
  },
}