Vue, firestore: how to display LIVE data after merging collections

Viewed 624

See EDIT Below


I have massively improved over my last question, but I am stuck again after some days of work.

Using Vue, Vue-router, Vuex and Vuetify with the Data on Googles Could Firestore

I want to update my data live, but i cannot find a way to do this. Do i need to restructure, like moving products and categories into one collection? Or is there any bind or query magic to get this done. As you can see below, it loads the data on click quite well, but I need the live binding 'cause you could have the page open and someone could sell the last piece (amountLeft = 0). (And a lot of future ideas).

My data structure is the following:

categories: {
  cat_food: {
    name: 'Food'
    parentCat: 'nC'
  },
  cat_drinks: {
    name: 'Food'
    parentCat: 'nC'
  },
  cat_beer: {
    name: 'Beer'
    parentCat: 'cat_drinks'
  },
  cat_spritz: {
    name: 'Spritzer'
    parentCat: 'cat_drinks'
  },
}

products: {
  prod_mara: {
    name: 'Maracuja Spritzer'
    price: 1.5
    amountLeft: 9
    cat: ['cat_spritz']
  },
  prod_capp: {
    name: 'Cappuccino'
    price: 2
    cat: ['cat_drinks']
  },
}

The categories and the products build a tree. The GIF shows me opening the categories down to show a product. You see that it's a product when you have a price tag. You can see there are two categories that have the same parent (cat_drinks). The product prod_capp is also assigned to the category and shown side by side to the categories.

Opening categories

I get the data currently this way:

catsOrProd.js

import { catsColl, productsColl } from '../firebase'

const state = {
  catOrProducts: [],
}

const mutations = {
  setCats(state, val) {
    state.catOrProducts = val
  }
}

const actions = {
  // https://vuefire.vuejs.org/api/vuexfire.html#firestoreaction

  async bindCatsWithProducts({ commit, dispatch }, CatID) {
    if (CatID) {
      // console.log('if CatID: ', CatID)
      await Promise.all([
        catsColl.where('parentCat', '==', CatID).orderBy('name', 'asc').get(),
        productsColl.where('cats', 'array-contains', CatID).orderBy('name', 'asc').get()
      ])
        .then(snap => dispatch('moveCatToArray', snap))
    } else {
      // console.log('else CatID: ', CatID)
      await Promise.all([
        catsColl.where('parentCat', '==', 'nC').orderBy('name', 'asc').get(),
        productsColl.where('cats', 'array-contains', 'nC').orderBy('name', 'asc').get()
      ])
        .then(snap => dispatch('moveCatToArray', snap))
    }
  },

  async moveCatToArray({ commit }, snap) {
    const catsArray = []
    // console.log(snap)
    await Promise.all([
      snap[0].forEach(cat => {
        catsArray.push({ id: cat.id, ...cat.data() })
      }),
      snap[1].forEach(cat => {
        catsArray.push({ id: cat.id, ...cat.data() })
      })
    ])
      .then(() => commit('setCats', catsArray))
  }
}

export default {
  namespaced: true,
  state,
  actions,
  mutations,
}

This is a part of my vue file that is showing the data on screen. I have left out the unnecessary parts. To open everything a have a route with props and clicking on the category sends the router to the next category. (this way i can move back with browser functionality). Sale.vue

<template>
...........
<v-col
  v-for="catOrProduct in catOrProducts"
  :key="catOrProduct.id"
  @click.prevent="leftClickProd($event, catOrProduct)"
  @contextmenu.prevent="rightClickProd($event, catOrProduct)">

....ViewMagic....
</v-col>
............
</template>

<script>
.........
  props: {
    catIdFromUrl: {
      type: String,
      default: undefined
    }
  },

  computed: {
    // https://stackoverflow.com/questions/40322404/vuejs-how-can-i-use-computed-property-with-v-for
    ...mapState('catOrProducts', ['catOrProducts']),
  },

  watch: {
    '$route.path'() { this.bindCatsWithProducts(this.catIdFromUrl) },
  },

  mounted() {
    this.bindCatsWithProducts(this.catIdFromUrl)
  },

  methods: {
    leftClickProd(event, catOrProd) {
      event.preventDefault()
      if (typeof (catOrProd.parentCat) === 'string') { // when parentCat exists we have a Category entry
        this.$router.push({ name: 'sale', params: { catIdFromUrl: catOrProd.id } })
        // this.bindCatsWithProducts(catOrProd.id)
      } else {
        // ToDo: Replace with buying-routine
        this.$refs.ProductMenu.open(catOrProd, event.clientX, event.clientY)
      }
    },
  }
</script>

EDIT 24.09.2020

I have changed my binding logic to

const mutations = {
  setCatProd(state, val) {
    state.catOrProducts = val
  },
}

const actions = {
async bindCatsWithProducts({ commit, dispatch }, CatID) {
    const contain = CatID || 'nC'
    const arr = []

    catsColl.where('parentCat', '==', contain).orderBy('name', 'asc')
      .onSnapshot(snap => {
        snap.forEach(cat => {
          arr.push({ id: cat.id, ...cat.data() })
        })
      })

    productsColl.where('cats', 'array-contains', contain).orderBy('name', 'asc')
      .onSnapshot(snap => {
        snap.forEach(prod => {
          arr.push({ id: prod.id, ...prod.data() })
        })
      })

    commit('setCatProd', arr)
  },
}

This works, as the data gets updated when I change something in the backend.

But now i get an object added everytime something changes. As example i've changed the price. Now i get this: Two products with identical ID's

I don't know why, because i have the key field set in Vue. The code for the rendering is:

<v-container fluid>
  <v-row
    align="center"
    justify="center"
  >
    <v-col
      v-for="catOrProduct in catOrProducts"
      :key="catOrProduct.id"
      @click.prevent="leftClickProd($event, catOrProduct)"
      @contextmenu.prevent="rightClickProd($event, catOrProduct)"
    >
      <div>
        <TileCat
          v-if="typeof(catOrProduct.parentCat) == 'string'"
          :src="catOrProduct.pictureURL"
          :name="catOrProduct.name"
        />
        <TileProduct
          v-if="catOrProduct.isSold"
          :name="catOrProduct.name"
          ... other props...
        />
      </div>
    </v-col>
  </v-row>
</v-container>

Why is this not updating correctly?

1 Answers

From the Vuefire docs, this is how you would subscribe to changes with Firebase only:

// get Firestore database instance
import firebase from 'firebase/app'
import 'firebase/firestore'

const db = firebase.initializeApp({ projectId: 'MY PROJECT ID' }).firestore()

new Vue({
  // setup the reactive todos property
  data: () => ({ todos: [] }),

  created() {
    // unsubscribe can be called to stop listening for changes
    const unsubscribe = db.collection('todos').onSnapshot(ref => {
      ref.docChanges().forEach(change => {
        const { newIndex, oldIndex, doc, type } = change
        if (type === 'added') {
          this.todos.splice(newIndex, 0, doc.data())
          // if we want to handle references we would do it here
        } else if (type === 'modified') {
          // remove the old one first
          this.todos.splice(oldIndex, 1)
          // if we want to handle references we would have to unsubscribe
          // from old references' listeners and subscribe to the new ones
          this.todos.splice(newIndex, 0, doc.data())
        } else if (type === 'removed') {
          this.todos.splice(oldIndex, 1)
          // if we want to handle references we need to unsubscribe
          // from old references
        }
      })
    }, onErrorHandler)
  },
})

I would generally avoid any unnecessary dependencies, but according to your objectives, you can use Vuefire to add another layer of abstraction, or as you said, doing some "magic binding".

import firebase from 'firebase/app'
import 'firebase/firestore'

const db = firebase.initializeApp({ projectId: 'MY PROJECT ID' }).firestore()

new Vue({
  // setup the reactive todos property
  data: () => ({ todos: [] }),
  firestore: {
    todos: db.collection('todos'),
  },
})
Related