mclintprojects / actioncable-vue

A Vue plugin that makes integrating Rails Action Cable dead-easy.
MIT License
181 stars 38 forks source link

Unable to access component methods or data #61

Closed cedric5 closed 4 weeks ago

cedric5 commented 2 years ago

Describe the bug I'm trying to call a method within my component when ever I receive a websocket call.

However method or data calls within one of the channel methods (connected, received etc.) will throw an error.

How do I access my the data and methods of my component?

To Reproduce Like the documentation I have declared my channel like this in the component.

    UpdateChannel: {
      received (data) {
        console.log('candidates:', this.candidates)
        this.fetchCandidates()
      }
    }
mounted () {
    this.$q.loading.show()
    this.$cable.subscribe({ channel: 'UpdateChannel', organization_id: this.currentOrg.id })
    this.fetchCandidates()
  }

Expected behaviour

Screenshots Whenever the the websocket update is triggerd

image

Plugin version (please complete the following information): In my yarn.lock

actioncable-vue@^2.5.0:
  version "2.5.0"
  resolved "https://registry.yarnpkg.com/actioncable-vue/-/actioncable-vue-2.5.0.tgz#b72689b7d1bb4d3b52ad21d23b55fc70573e2067"
  integrity sha512-KIYvMZ60DWUjHESWUOTDXBptpUzdAX3OlgRc8A4NPOy+8sYTXUnioqd7EDQ/gPj/tFCH3WA/ipqPXfnfVZEJqA==
  dependencies:
    "@rails/actioncable" "^6.0.2"
    "@types/actioncable" "^5.2.3"

Additional context I am using quasar

cedric5 commented 2 years ago

Still experiencing this issue. Is this project still maintained?

x8BitRain commented 2 years ago

Still experiencing this issue. Is this project still maintained?

Where is this.fetchCandidates() located within the file? Can you please post a the full component or link a repo?

cedric5 commented 2 years ago

A while ago I changed something that resolved this issue, I could not pinpoint what it was. But today I fell upon the same issue again and cant figure it out what is wrong exactly

So now I have a component where everything is working as expected:

.row(style="height:100%")
  .col-md-9(style="width:100%; height:92%;")
    q-scroll-area(ref='scrollArea' style='height: 100%; max-width: 100%;')
      div(v-if="messages.length > 0"  v-for="message in messages" style='width: 100%; padding-left:24px; padding-right:24px;')
        q-chat-message(v-if="message.originatorRole === 'bot' || message.originatorRole === 'user'" name='Agent' avatar='https://recrubo.com/wp-content/themes/recrubo/dist/images/recrubo-logo.png' :text="[message.fallback]" sent :stamp='message.createdAt')
        q-chat-message(v-if="message.originatorRole === 'external'" :name='conversation.candidate.name' avatar='https://icon-library.com/images/avatar-icon-png/avatar-icon-png-11.jpg' :text="[message.fallback]" :stamp='message.createdAt')
      div(v-else)
        .text-center.absolute-center.text-body1 Please select a conversation
  .row(style="width: 100%;")
    q-input(@keyup.enter="sendMessage()" autogrow  style="padding-left:8px; padding-right:12px; width:100%;" bg-color="white" rounded outlined v-model='message' label='Type your message..')
      template(v-slot:append)
        q-btn(@click="sendMessage()" round dense flat icon='send')
      template(v-slot:after)
</template>
<script>
import { defineComponent } from 'vue'
import _ from 'lodash'
import backendApi from '@/api/backend'
import { mapGetters } from 'vuex'

export default defineComponent({
  channels: {
    ChatChannel: {
      received (data) {
        const message = data.attributes
        message.id = data.id
        this.messages.push(message)
        this.messages = _.orderBy(this.messages, ['createdAt'], ['ASC'])
        this.scrollToBottom()
        const conversation = _.cloneDeep(this.conversation)
        conversation.unreadMessages = false
        this.$emit('updateConversation', conversation)
        backendApi.update('conversation', this.conversation).then(() => {}) // Updating unread status in backend
      }
    }
  },
  props: {
    conversation: Object
  },
  data () {
    return {
      messages: [],
      message: ''
    }
  },
  computed: {
    ...mapGetters('user', ['currentOrg'])
  },
  methods: {
    fetchMessages () {
      this.$q.loading.show()
      backendApi.find('flow-thread', this.conversation.flowThread.id, { include: ['messages'] })
        .then((data) => {
          this.messages = []
          this.messages.push(data.data.messages)
          this.messages = _.flatten(this.messages)
          this.messages = _.orderBy(this.messages, ['createdAt'], ['ASC'])
        }).finally(() => {
          this.scrollToBottom()
        })
    },
    sendMessage () {
      backendApi.request(process.env.API_URL + '/messaging/send-message/' + this.conversation.flowThread.id, 'POST', {}, { message: this.message })
        .then((data) => {
          this.message = ''
          this.messages.push(data.data)
          this.messages = _.flatten(this.messages)
          this.messages = _.orderBy(this.messages, ['createdAt'], ['ASC'])
        }).finally(() => {
          this.scrollToBottom()
        })
    },
    scrollToBottom () {
      setTimeout(() => {
        this.$refs.scrollArea.setScrollPercentage('vertical', 1)
        this.$q.loading.hide()
      }, 300)
    }
  },

  mounted () {
    this.$cable.subscribe({
      channel: 'ChatChannel',
      thread_id: this.conversation.flowThread.id
    })
  },
  watch: {
    conversation: {
      immediate: true,
      handler () {
        this.fetchMessages()
      }
    }
  }

})
</script>

And the other component that I was working on today where I am experiencing the same issue as described before.

.row
  .col-md-12(style="height: 50%;")
    q-infinite-scroll(@load='onload' :offset='500')
      div(v-if="conversations.length > 0" v-for='conversation in conversations' :key='conversation.id')
        q-intersection.example-item(transition='scale')
          q-item(clickable v-ripple @click="setSelectedConversation(conversation)")
            q-item-section(avatar)
              q-avatar(color='primary' text-color='white')
                | {{ avatarInitials(conversation.candidate) }}
            q-item-section
              q-item-label {{ conversation.candidate.fullName }}
              q-item-label(caption lines='1') {{ conversation.flowThread.channel }}
            q-item-section(side v-if="conversation.unreadMessages")
              q-icon(name='fiber_manual_record' size='xs' color='green')
      div(v-if="allConversationsLoaded")
        q-separator(style="margin-top:12px" inset)
        .text-subtitle1.text-center(style="margin-top:12px; color: #909396; padding-bottom:24px;") All conversations loaded
      template(v-if="!allConversationsLoaded" v-slot:loading)
        .row.justify-center.q-my-md
          q-spinner-dots(color='primary' size='40px')
</template>
<script>
import { defineComponent } from 'vue'
import _ from 'lodash'
import backendApi from '@/api/backend'
import { mapGetters } from 'vuex'

export default defineComponent({
  channels: {
    ConversationChannel: {
      received (data) {
        const conversation = data.attributes
        conversation.id = data.id
        const index = _.findIndex(this.conversations, { id: conversation.id })
        this.conversations[index] = conversation
      }
    }
  },
  data () {
    return {
      conversations: [],
      conversationPage: 1,
      maxConversationPage: 0
    }
  },
  computed: {
    ...mapGetters('user', ['currentUser']),
    allConversationsLoaded () {
      return this.conversationPage > this.maxConversationPage
    }
  },
  methods: {
    avatarInitials (candidate) {
      return candidate.name.substr(0, 1).toUpperCase()
    },
    onload (index, done) {
      if (!this.allConversationsLoaded) {
        setTimeout(() => {
          this.conversationPage += 1
          backendApi.findAll('conversations', { page: this.conversationPage, size: 30, include: ['user', 'candidate', 'flowThread'] })
            .then((data) => {
              this.maxConversationPage = data.meta.pageCount
              this.conversations = _.concat(this.conversations, data.data)
            })
            .finally(() => {
              done()
            })
        }, 2000)
      } else {
        done()
      }
    },
    setSelectedConversation (conversation) {
      conversation.unreadMessages = false
      backendApi.update('conversation', conversation).then(() => {}) // Updating unread status in backend
      this.$emit('selectedConversation', conversation)
    },
    fetchConversations () {
      backendApi.findAll('conversations', { page: this.conversationPage, size: 30, include: ['user', 'candidate', 'flowThread'] })
        .then((data) => {
          this.maxConversationPage = data.meta.pageCount
          this.conversations = _.concat(this.conversations, data.data)
        })
        .finally(() => {
          this.fetching = false
        })
    }
  },
  mounted () {
    this.fetchConversations()
    this.$cable.subscribe({
      channel: 'ConversationChannel',
      organization_id: this.currentUser.organization.id
    })
  }
})
</script>

Which results in a Uncaught TypeError: this.conversations is undefined Neither calling a method as in the issue description is working

I have tried recompiling multiple times.

cedric5 commented 2 years ago

After some hours of debugging I found the following:

I made a simple test component named Test without all the clutter.

<template lang="pug">
</template>
<script>
import { defineComponent } from 'vue'
export default defineComponent({
  channels: {
    ConversationChannel: {
      connected () {
        console.log('connected')
      },
      received (data) {
        this.conversations.push(data)
        console.log(this.conversations)
      }
    }
  },
  data () {
    return {
      conversations: []
    }
  },
  mounted () {
    this.$cable.subscribe({
      channel: 'ConversationChannel',
      organization_id: '2f406421-ca98-43f7-9c6a-d6183b935afa'
    })
  }

})
</script>

I have a page where I initialize this component

<template lang="pug">
div
  q-splitter(v-model='splitterModel' style='height: 92.5vh;')
    template(v-slot:before)
      conversations(@selectedConversation="setSelectedConversation($event)")
    template(v-slot:after)
      q-splitter(v-model='splitterModel2')
        template(v-slot:before)
          test(v-if="selectedConversation")
        template(v-slot:after)
          .col-md-3
            candidate-card(v-if="selectedConversation" :basicMode="true" :candidate="selectedConversation.candidate")
</template>

Whener I use a v-for to decide to render the component it all works as expected. How ever if I remove the v-for the above mentioned errors appear.

test(v-if="selectedConversation") works test() does not work

edit:

Using test(v-if="selectedConversation") works but if you afterwards subscribe to another channel in another component but within the same parent component the errors recur again. It looks like the context of the channels {} block seems to be changing and can not handle multiple subscription within the same (parent) component.

I will try to create a demo project one of these days.

phlegx commented 7 months ago

" It looks like the context of the channels {} block seems to be changing and can not handle multiple subscription within the same (parent) component."

Can confirm! If the subscription is in a parent component, the child components with subscriptions have the context of the parent.

phlegx commented 7 months ago

The problem is, that actioncable-vue uses context._uid in some parts of the code (e.g. src/cable.js#L231).

Vue 3 don't uses this._uid anymore. For the options API it can be retrieved with this.$.uid. For composition API the method call getCurrentInstance().uid should be used.

Here two workarounds for options API and composition API.

Options API

/* cable.js: Small constant export to use it as mixin. */
export const ActionCable = {
  beforeCreate() {
    this._uid = this.$.uid
  },
}

/* In the component include cable.js as mixin. */
import { ActionCable } from 'cable.js'
export default {
  ...
  mixins: [ActionCable],
  ...
}

Composition API

import { getCurrentInstance } from 'vue'
...
app.mixin({
  beforeCreate() {
    this._uid = getCurrentInstance().uid
  },
})

Related to #49: created hook want work, so we need to use beforeCreate hook. @mclintprojects