diff --git a/app/javascript/dashboard/api/captain/faqSuggestions.js b/app/javascript/dashboard/api/captain/faqSuggestions.js index f031b3f9b..d1f1e7474 100644 --- a/app/javascript/dashboard/api/captain/faqSuggestions.js +++ b/app/javascript/dashboard/api/captain/faqSuggestions.js @@ -6,7 +6,7 @@ class CaptainFaqSuggestions extends ApiClient { super('captain/faq_suggestions', { accountScoped: true }); } - get({ page = 1, search, assistantId, status = 'open' } = {}) { + get({ page = 1, search, assistantId, status = 'open', signal } = {}) { return axios.get(this.url, { params: { page, @@ -14,6 +14,7 @@ class CaptainFaqSuggestions extends ApiClient { assistant_id: assistantId, status, }, + signal, }); } diff --git a/app/javascript/dashboard/api/captain/response.js b/app/javascript/dashboard/api/captain/response.js index 114bde67d..6e4ccf5fe 100644 --- a/app/javascript/dashboard/api/captain/response.js +++ b/app/javascript/dashboard/api/captain/response.js @@ -6,7 +6,7 @@ class CaptainResponses extends ApiClient { super('captain/assistant_responses', { accountScoped: true }); } - get({ page = 1, search, assistantId, documentId } = {}) { + get({ page = 1, search, assistantId, documentId, signal } = {}) { return axios.get(this.url, { params: { page, @@ -14,6 +14,7 @@ class CaptainResponses extends ApiClient { assistant_id: assistantId, document_id: documentId, }, + signal, }); } } diff --git a/app/javascript/dashboard/routes/dashboard/captain/responses/FaqSuggestions.spec.js b/app/javascript/dashboard/routes/dashboard/captain/responses/FaqSuggestions.spec.js index e72cc5a08..13d93a21f 100644 --- a/app/javascript/dashboard/routes/dashboard/captain/responses/FaqSuggestions.spec.js +++ b/app/javascript/dashboard/routes/dashboard/captain/responses/FaqSuggestions.spec.js @@ -54,10 +54,12 @@ vi.mock('vue-router', async importOriginal => { const deferred = () => { let resolve; - const promise = new Promise(resolvePromise => { + let reject; + const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; + reject = rejectPromise; }); - return { promise, resolve }; + return { promise, resolve, reject }; }; const PageLayoutStub = { @@ -91,12 +93,20 @@ describe('FaqSuggestions', () => { }); }); - it('keeps the latest assistant results when requests finish in the wrong order', async () => { + it('keeps the latest assistant results when an older request is superseded', async () => { const firstRequest = deferred(); const secondRequest = deferred(); - mocks.apiGet - .mockReturnValueOnce(firstRequest.promise) - .mockReturnValueOnce(secondRequest.promise); + const queued = [firstRequest, secondRequest]; + + mocks.apiGet.mockImplementation(({ signal }) => { + const request = queued.shift(); + signal.addEventListener('abort', () => { + const error = new Error('canceled'); + error.name = 'CanceledError'; + request.reject(error); + }); + return request.promise; + }); const wrapper = shallowMount(FaqSuggestions, { global: { @@ -109,6 +119,7 @@ describe('FaqSuggestions', () => { }); await flushPromises(); + // Switching assistants aborts the first request before it resolves. mocks.route.params.assistantId = 2; await flushPromises(); @@ -120,14 +131,6 @@ describe('FaqSuggestions', () => { }); await flushPromises(); - firstRequest.resolve({ - data: { - payload: [{ id: 1, question: 'Previous assistant' }], - meta: { page: 1, total_count: 1 }, - }, - }); - await flushPromises(); - expect(wrapper.text()).toContain('Current assistant'); expect(wrapper.text()).not.toContain('Previous assistant'); diff --git a/app/javascript/dashboard/routes/dashboard/captain/responses/FaqSuggestions.vue b/app/javascript/dashboard/routes/dashboard/captain/responses/FaqSuggestions.vue index 90b84419e..a1043a165 100644 --- a/app/javascript/dashboard/routes/dashboard/captain/responses/FaqSuggestions.vue +++ b/app/javascript/dashboard/routes/dashboard/captain/responses/FaqSuggestions.vue @@ -5,6 +5,7 @@ import { useRoute, useRouter } from 'vue-router'; import { debounce } from '@chatwoot/utils'; import { useAlert } from 'dashboard/composables'; import { useMapGetter, useStore } from 'dashboard/composables/store'; +import { useAbortableRequest } from 'dashboard/composables/useAbortableRequest'; import { FEATURE_FLAGS } from 'dashboard/featureFlags'; import CaptainFaqSuggestionsAPI from 'dashboard/api/captain/faqSuggestions'; @@ -51,47 +52,32 @@ const updateURL = (page, search) => { router.replace({ query }); }; -let suggestionsRequestId = 0; -let fetchingListRequestId = null; - -const isCurrentSuggestionRequest = (requestId, assistantId) => - requestId === suggestionsRequestId && - assistantId === selectedAssistantId.value; +const { run: runListRequest, abort: abortListRequest } = useAbortableRequest(); const fetchSuggestions = async (page = 1) => { - suggestionsRequestId += 1; - const requestId = suggestionsRequestId; - const assistantId = selectedAssistantId.value; - updateURL(page, searchQuery.value); - fetchingListRequestId = requestId; store.dispatch('captainFaqSuggestions/setFetchingList', true); try { - const response = await CaptainFaqSuggestionsAPI.get({ - page, - search: searchQuery.value, - assistantId, - }); + const response = await runListRequest(signal => + CaptainFaqSuggestionsAPI.get({ + page, + search: searchQuery.value, + assistantId: selectedAssistantId.value, + signal, + }) + ); - if (!isCurrentSuggestionRequest(requestId, assistantId)) return []; + if (!response) return; - const { payload, meta } = response.data; store.dispatch('captainFaqSuggestions/setRecords', { - records: payload, - meta, + records: response.data.payload, + meta: response.data.meta, }); - return payload; + store.dispatch('captainFaqSuggestions/setFetchingList', false); } catch (error) { - if (isCurrentSuggestionRequest(requestId, assistantId)) { - useAlert(error?.message || t('CAPTAIN.FAQ_SUGGESTIONS.ERRORS.LOAD')); - } - return []; - } finally { - if (fetchingListRequestId === requestId) { - fetchingListRequestId = null; - store.dispatch('captainFaqSuggestions/setFetchingList', false); - } + useAlert(error?.message || t('CAPTAIN.FAQ_SUGGESTIONS.ERRORS.LOAD')); + store.dispatch('captainFaqSuggestions/setFetchingList', false); } }; @@ -150,7 +136,7 @@ const handleResolved = () => { const debouncedSearch = debounce(() => fetchSuggestions(1), 500); const handleSearchInput = () => { - suggestionsRequestId += 1; + abortListRequest(); debouncedSearch(); }; @@ -179,7 +165,6 @@ watch( ); onUnmounted(() => { - suggestionsRequestId += 1; store.dispatch('captainFaqSuggestions/setFetchingList', false); }); diff --git a/app/javascript/dashboard/routes/dashboard/captain/responses/Index.vue b/app/javascript/dashboard/routes/dashboard/captain/responses/Index.vue index 197fdf717..23c1679b3 100644 --- a/app/javascript/dashboard/routes/dashboard/captain/responses/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/captain/responses/Index.vue @@ -2,6 +2,7 @@ import { computed, onUnmounted, ref, nextTick, watch } from 'vue'; import { useMapGetter, useStore } from 'dashboard/composables/store'; import { useAlert } from 'dashboard/composables'; +import { useAbortableRequest } from 'dashboard/composables/useAbortableRequest'; import { useI18n } from 'vue-i18n'; import { useRouter, useRoute } from 'vue-router'; import { FEATURE_FLAGS } from 'dashboard/featureFlags'; @@ -97,20 +98,13 @@ const updateURLWithFilters = (page, search) => { router.replace({ query }); }; -let responsesRequestId = 0; -let fetchingListRequestId = null; - -const isCurrentResponseRequest = (requestId, assistantId) => - requestId === responsesRequestId && assistantId === selectedAssistantId.value; +const { run: runListRequest, abort: abortListRequest } = useAbortableRequest(); const fetchResponses = async (page = 1) => { - responsesRequestId += 1; - const requestId = responsesRequestId; - const assistantId = selectedAssistantId.value; const filterParams = { page }; - if (assistantId) { - filterParams.assistantId = assistantId; + if (selectedAssistantId.value) { + filterParams.assistantId = selectedAssistantId.value; } if (searchQuery.value) { filterParams.search = searchQuery.value; @@ -119,30 +113,23 @@ const fetchResponses = async (page = 1) => { // Update URL with current filters updateURLWithFilters(page, searchQuery.value); - fetchingListRequestId = requestId; store.dispatch('captainResponses/setFetchingList', true); try { - const response = await CaptainResponseAPI.get(filterParams); + const response = await runListRequest(signal => + CaptainResponseAPI.get({ ...filterParams, signal }) + ); - if (!isCurrentResponseRequest(requestId, assistantId)) return []; + if (!response) return; - const { payload, meta } = response.data; store.dispatch('captainResponses/setRecords', { - records: payload, - meta, + records: response.data.payload, + meta: response.data.meta, }); - return payload; + store.dispatch('captainResponses/setFetchingList', false); } catch (error) { - if (isCurrentResponseRequest(requestId, assistantId)) { - useAlert(error?.message || t('CAPTAIN.RESPONSES.ERRORS.LOAD')); - } - return []; - } finally { - if (fetchingListRequestId === requestId) { - fetchingListRequestId = null; - store.dispatch('captainResponses/setFetchingList', false); - } + useAlert(error?.message || t('CAPTAIN.RESPONSES.ERRORS.LOAD')); + store.dispatch('captainResponses/setFetchingList', false); } }; @@ -217,7 +204,7 @@ const debouncedSearch = debounce(async () => { }, 500); const handleSearchInput = () => { - responsesRequestId += 1; + abortListRequest(); debouncedSearch(); }; @@ -256,7 +243,6 @@ watch( ); onUnmounted(() => { - responsesRequestId += 1; store.dispatch('captainResponses/setFetchingList', false); });