Search code examples
javascriptarraysvue.jsbootstrap-4bootstrap-vue

How do I match a computed property to a bootstrap popover inside a nested v-for loop?


Before typing this question up I have spent a number of days looking for an example that was close enough to help to no avail and so I'm bringing my specific use case to SO

I am working on a layout with Bootstrap Vue where I'm loading in dates into into 12 buttons that correspond to months and then have a popover show above each button that includes the matching dates to that month which are loaded in from Firebase. So far I have almost the desired behavior in that I use a v-for to loop through a months array of objects that contains the month name and an array for holding matching dates for that month which lays out my buttons based on the month name and another v-for loop inside those buttons for the popover where I load in all the dates coming from Firebase.

I also have a computed property where I shorten the incoming dates from Firebase to their shortened month names and then use nested for-in loops on both the months array and my dates array that takes in the Firebase dates to match the shortened months to the existing arrays inside my months array of objects. I will include photos of what I just said for clarity below

enter image description here

enter image description here <-- Each button contains a popover like this, I want to matched the months in the popover to the right month button and only that one

Where I'm stuck at right now is how to go about connecting my computed property to the nested v-for loop of the popover so that only the matching dates show up for any month. Initially I tried moving the v-for to the button, but all that did was created multiple buttons for each incoming date and for each month name in the first v-for loop. Other example questions I've seen here for nested v-fors contain solutions when the data is coming from a single array it seems, but in my case, I have separate data in separate arrays that I would like to match i.e(Jan button should only have January dates, Feb button should only have February dates and so one and so forth). I will paste the relevant code blocks I have now below

From the template:

<template>
  <b-container>
    <b-row>
        <div v-for="(month,index) in months" :key="index">
            <b-button 
                :id="`date-popover-${month.name}`"
                class="shadeCircleColor" 
                :to="{ name: 'PaymentsDetailPage', params: { id: id } }"
            >
                {{month.name}}
            </b-button>
            <b-popover
                :target="`date-popover-${month.name}`"
                triggers="hover"
                placement="top"
            >
              <div v-for="(payment, index) in payments" :key="index">
                {{payment.createdOn}}
              </div>
            </b-popover>
        </div>
        {{ matchedMonths }}
    </b-row>
  </b-container>
</template>

From the data():

data() {
    return {
      dates: [], <-- dates from firebase will show here in from a property  i.e(dates.createdOn)
      months: [ 
        {name: 'Jan', createdOn: [] }, 
        {name: 'Feb', createdOn: [] }, 
        {name: 'Mar', createdOn: [] }, 
        {name: 'Apr', createdOn: [] }, 
        {name: 'May', createdOn: [] }, 
        {name: 'Jun', createdOn: [] }, 
        {name: 'Jul', createdOn: [] }, 
        {name: 'Aug', createdOn: [] }, 
        {name: 'Sep', createdOn: [] }, 
        {name: 'Oct', createdOn: [] }, 
        {name: 'Nov', createdOn: [] }, 
        {name: 'Dec', createdOn: [] }, 
      ],
    };
  },

From the computed property:

computed: {
   matchedMonths() {
    for(let i in this.months) {
     for(let j in this.dates) {
      var dates = new Date(this.dates[j].createdOn)
      const shortMonth = dates.toLocaleString('default', { month: 'short' });
      if(shortMonth === this.months[i].name) {
        this.months[i].createdOn.push(shortMonth)
        console.log(this.months[i].name, this.months[i].createdOn)
      }
     }
    }
   }
  },

Where I'd say Im stuck at is how to return the matched months I want to the right v-for so the popovers only show one date for each of the months. One issue I suspected was with my nested for-in loops since when I try to return the shortened months to the months.createdOn array it makes all the buttons go away. What I've tried since then is changing the loop to use the .map function on the dates array and then somehow match that to the months array but I've run to undefined errors when I try to push those dates in.

Here is the code illustrating that below:

const justDates = this.payments.map(payment => {
  return new Date(payment.createdOn).toLocaleString('default', { month: 'short'})
})
console.log(justDates)
const months= this.months.map(month => {
  if(justDates === month.name){
    return month.createdOn.push(justDates)
  }
})
console.log(months)

I don't know where to put the matchedMonths computed property to get this to work properly or if I should just use a v-if in either v-for loop to check if the month.name === to the shortened month name of the incoming dates and would appreciate help on how to get the desired solution.

What I also need help on is changing the shortened month dates back into their full month day and year string once I do get the right shortened months matched to their buttons. Is that code that also goes in the computed property? Thank you for your time.


Solution

  • I would say that don't mess with dates (datetime) - this topic is notoriously complicated, bugs appear where you least expect.

    Use a proper date handling library, where people have already worked out a lot of the usual use-cases (and a lot of edge-cases): my usual choice is dayjs, for example.

    But in your case this might not be necessary. If you only want to sort date strings based on the month, than that seems pretty straightforward:

    const dates = [
      '2021-10-04',
      '2021-09-08',
      '2021-08-06',
      '2021-07-02',
      '2021-05-04',
      '2021-01-20',
      '2021-02-11',
      '2021-03-14',
      '2021-04-10',
      '2021-06-15',
      '2021-11-16',
      '2021-12-28',
    ]
    
    const getMonthFromDate = (s) => {
      // with Date.prototype.getMonth() January is 0!
      return new Date(s).getMonth()
    }
    
    const mappedMonths = dates.map(getMonthFromDate)
    
    console.log(mappedMonths)

    So, if your input data (that comes from Firebase) is "parsable" by new Date(), then you're done with the first part: you have the month (as a number from 0 to 11). If not, then you still can go to dayjs and define the format you expect the dates to arrive in.

    Here's another snippet that helps you put this into action:

    Vue.component('ButtonWithPopover', {
      props: ['item'],
      computed: {
        targetId() {
          return `date-popover-${this.item.name}`
        },
      },
      template: `
        <div>
          <b-button
            :id="targetId"
          >
            {{ item.name }}
          </b-button>
          <b-popover
            :target="targetId"
            triggers="hover"
            placement="bottom"
          >
            <template #title>
              {{ item.name }}:
            </template>
            <div
              v-for="createdOnItem in item.createdOn"
              :key="createdOnItem"
            >
              {{ createdOnItem }}
            </div>
          </b-popover>
        </div>
      `
    })
    
    new Vue({
      el: "#app",
      data() {
        return {
          dates: [
            '2021-10-04',
            '2021-09-08',
            '2021-08-06',
            '2021-07-02',
            '2021-05-04',
            '2021-01-20',
            '2021-02-11',
            '2021-03-14',
            '2021-04-10',
            '2021-06-15',
            '2021-11-16',
            '2021-12-28',
            '2021-02-03', // I added this, to show that multiple lines can appear in a month popover
          ],
          months: [{
              name: 'Jan',
            },
            {
              name: 'Feb',
            },
            {
              name: 'Mar',
            },
            {
              name: 'Apr',
            },
            {
              name: 'May',
            },
            {
              name: 'Jun',
            },
            {
              name: 'Jul',
            },
            {
              name: 'Aug',
            },
            {
              name: 'Sep',
            },
            {
              name: 'Oct',
            },
            {
              name: 'Nov',
            },
            {
              name: 'Dec',
            },
          ],
        }
      },
      computed: {
        // this computed merges the months with the
        // available dates; as it's a computed, it
        // updates if the data it depends on updates
        monthItems() {
          return this.months.map((e, i) => {
            const createdOn = this.getFilteredDate(i, this.dates)
            return {
              ...e,
              createdOn,
            }
          })
        },
      },
      methods: {
        getMonthFromDate(date) {
          return new Date(date).getMonth()
        },
        getFilteredDate(idx, dates) {
          return dates.filter(date => {
            return this.getMonthFromDate(date) === idx
          }) || []
        },
      },
      template: `
        <b-container
          class="py-2"
        >
          <b-row>
            <b-col
              class="d-flex"
            >
              <button-with-popover
                v-for="item in monthItems"
                :key="item.name"
                :item="item"
              />
            </b-col>
          </b-row>
        </b-container>
      `
    })
    <!-- Add this to <head> -->
    
    <!-- Load required Bootstrap and BootstrapVue CSS -->
    <link type="text/css" rel="stylesheet" href="//unpkg.com/bootstrap/dist/css/bootstrap.min.css" />
    <link type="text/css" rel="stylesheet" href="//unpkg.com/bootstrap-vue@latest/dist/bootstrap-vue.min.css" />
    
    <!-- Load polyfills to support older browsers -->
    <script src="//polyfill.io/v3/polyfill.min.js?features=es2015%2CIntersectionObserver" crossorigin="anonymous"></script>
    
    <!-- Load Vue followed by BootstrapVue -->
    <script src="//unpkg.com/vue@latest/dist/vue.min.js"></script>
    <script src="//unpkg.com/bootstrap-vue@latest/dist/bootstrap-vue.min.js"></script>
    
    <!-- Load the following for BootstrapVueIcons support -->
    <script src="//unpkg.com/bootstrap-vue@latest/dist/bootstrap-vue-icons.min.js"></script>
    
    <div id="app"></div>