fix(captain): clean up FAQ suggestion review flow

This commit is contained in:
aakashb95
2026-07-21 12:11:55 +05:30
parent 4d22d2c18e
commit 2485bc242d
25 changed files with 541 additions and 259 deletions
@@ -6,14 +6,13 @@ class CaptainResponses extends ApiClient {
super('captain/assistant_responses', { accountScoped: true });
}
get({ page = 1, search, assistantId, documentId, status } = {}) {
get({ page = 1, search, assistantId, documentId } = {}) {
return axios.get(this.url, {
params: {
page,
search,
assistant_id: assistantId,
document_id: documentId,
status,
},
});
}
@@ -7,24 +7,7 @@ import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCa
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
import { responsesList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
import { computed } from 'vue';
const props = defineProps({
variant: {
type: String,
default: 'approved',
validator: value => ['approved', 'pending'].includes(value),
},
hasActiveFilters: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['click', 'clearFilters']);
const isApproved = computed(() => props.variant === 'approved');
const isPending = computed(() => props.variant === 'pending');
const emit = defineEmits(['click']);
const { isOnChatwootCloud } = useAccount();
const { replaceInstallationName } = useBranding();
@@ -32,15 +15,10 @@ const { replaceInstallationName } = useBranding();
const onClick = () => {
emit('click');
};
const onClearFilters = () => {
emit('clearFilters');
};
</script>
<template>
<FeatureSpotlight
v-if="isApproved"
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
:note="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
fallback-thumbnail="/assets/images/dashboard/captain/faqs-light.svg"
@@ -50,16 +28,12 @@ const onClearFilters = () => {
class="mb-8"
/>
<EmptyStateLayout
:title="
isPending
? $t('CAPTAIN.RESPONSES.EMPTY_STATE.NO_PENDING_TITLE')
: $t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')
"
:subtitle="isApproved ? $t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE') : ''"
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')"
:subtitle="$t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE')"
:action-perms="['administrator']"
:show-backdrop="isApproved"
show-backdrop
>
<template v-if="isApproved" #empty-state-item>
<template #empty-state-item>
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
<ResponseCard
v-for="(response, index) in responsesList.slice(0, 5)"
@@ -77,18 +51,10 @@ const onClearFilters = () => {
<template #actions>
<div class="flex flex-col items-center gap-3">
<Button
v-if="isApproved"
:label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
icon="i-lucide-plus"
@click="onClick"
/>
<Button
v-else-if="isPending && hasActiveFilters"
:label="$t('CAPTAIN.RESPONSES.EMPTY_STATE.CLEAR_SEARCH')"
variant="link"
size="sm"
@click="onClearFilters"
/>
</div>
</template>
</EmptyStateLayout>
@@ -6,7 +6,12 @@ import { LocalStorage } from 'shared/helpers/localStorage';
const props = defineProps({
knowledge: {
type: Object,
default: () => ({ approved: 0, pending: 0, documents: 0, coverage: 0 }),
default: () => ({
approved: 0,
suggestions: 0,
documents: 0,
coverage: 0,
}),
},
});
@@ -31,16 +36,16 @@ watch(
{ immediate: true }
);
// Thin coverage paired with a large review backlog: approving the pending FAQs
// Thin coverage paired with a large review backlog: approving FAQ suggestions
// is the quickest lever to lift auto-resolution, so nudge the team to act.
const COVERAGE_THRESHOLD = 85;
const PENDING_THRESHOLD = 100;
const SUGGESTION_THRESHOLD = 100;
const showBanner = computed(
() =>
!dismissed.value &&
(props.knowledge?.coverage ?? 0) < COVERAGE_THRESHOLD &&
(props.knowledge?.pending ?? 0) > PENDING_THRESHOLD
(props.knowledge?.suggestions ?? 0) > SUGGESTION_THRESHOLD
);
const dismiss = () => {
@@ -48,9 +53,9 @@ const dismiss = () => {
dismissed.value = true;
};
const goToPending = () => {
const goToSuggestions = () => {
router.push({
name: 'captain_assistants_responses_pending',
name: 'captain_assistants_faq_suggestions',
params: {
accountId: route.params.accountId,
assistantId: route.params.assistantId,
@@ -61,15 +66,15 @@ const goToPending = () => {
<template>
<div
v-if="showBanner"
class="flex items-center justify-between gap-3 px-3 py-2 text-sm border rounded-xl bg-n-amber-3 border-n-amber-4 text-n-amber-11"
:class="{ hidden: !showBanner }"
>
<div class="flex items-center gap-2 min-w-0">
<span class="shrink-0 i-lucide-triangle-alert size-4" />
<span class="truncate">
{{
$t('CAPTAIN.OVERVIEW.COVERAGE_BANNER.TEXT', {
count: knowledge.pending,
count: knowledge.suggestions,
coverage: knowledge.coverage,
})
}}
@@ -79,7 +84,7 @@ const goToPending = () => {
<button
type="button"
class="px-3 py-1 rounded-lg bg-n-amber-4 hover:bg-n-amber-5"
@click="goToPending"
@click="goToSuggestions"
>
{{ $t('CAPTAIN.OVERVIEW.COVERAGE_BANNER.ACTION') }}
</button>
@@ -6,7 +6,12 @@ import { useI18n } from 'vue-i18n';
const props = defineProps({
knowledge: {
type: Object,
default: () => ({ approved: 0, pending: 0, documents: 0, coverage: 0 }),
default: () => ({
approved: 0,
suggestions: 0,
documents: 0,
coverage: 0,
}),
},
});
@@ -31,10 +36,10 @@ const stats = computed(() => [
to: linkTo('captain_assistants_responses_index'),
},
{
key: 'pending',
value: props.knowledge.pending,
label: t('CAPTAIN.OVERVIEW.KNOWLEDGE.PENDING'),
to: linkTo('captain_assistants_responses_pending'),
key: 'suggestions',
value: props.knowledge.suggestions,
label: t('CAPTAIN.OVERVIEW.KNOWLEDGE.SUGGESTIONS'),
to: linkTo('captain_assistants_faq_suggestions'),
},
{
key: 'documents',
@@ -0,0 +1,74 @@
import { flushPromises, shallowMount } from '@vue/test-utils';
import FaqSuggestionReviewDialog from './FaqSuggestionReviewDialog.vue';
const { dispatch, uiFlags } = vi.hoisted(() => ({
dispatch: vi.fn(),
uiFlags: {
value: {
fetchingItem: false,
updatingItem: false,
deletingItem: false,
},
},
}));
vi.mock('dashboard/composables/store', () => ({
useStore: () => ({ dispatch }),
useMapGetter: () => uiFlags,
}));
vi.mock('dashboard/composables', () => ({ useAlert: vi.fn() }));
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: key => key }),
}));
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn() }),
}));
const DialogStub = {
template: `
<div>
<slot name="description" />
<slot />
<slot name="footer" />
</div>
`,
};
describe('FaqSuggestionReviewDialog', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('keeps a visible error when source conversations fail to load', async () => {
dispatch.mockRejectedValueOnce(new Error('Request failed'));
const wrapper = shallowMount(FaqSuggestionReviewDialog, {
props: {
suggestion: {
id: 1,
question: 'How do I enable the feature?',
answer: 'Turn it on in settings.',
source_count: 2,
assistant: { name: 'Support assistant' },
language: 'en',
},
},
global: {
mocks: { $t: key => key },
stubs: { Dialog: DialogStub },
},
});
await flushPromises();
expect(wrapper.get('[role="alert"]').text()).toContain(
'CAPTAIN.FAQ_SUGGESTIONS.ERRORS.LOAD_DETAILS'
);
expect(wrapper.text()).not.toContain(
'CAPTAIN.FAQ_SUGGESTIONS.DETAILS.NO_SOURCES'
);
});
});
@@ -27,6 +27,7 @@ const router = useRouter();
const store = useStore();
const dialogRef = ref(null);
const details = ref(null);
const detailsError = ref(false);
const uiFlags = useMapGetter('captainFaqSuggestions/getUIFlags');
const isFetching = computed(() => uiFlags.value.fetchingItem);
@@ -44,12 +45,15 @@ const isInvalid = computed(
);
const loadDetails = async () => {
detailsError.value = false;
try {
details.value = await store.dispatch(
'captainFaqSuggestions/show',
props.suggestion.id
);
} catch (error) {
detailsError.value = true;
useAlert(
error?.message || t('CAPTAIN.FAQ_SUGGESTIONS.ERRORS.LOAD_DETAILS')
);
@@ -187,6 +191,23 @@ defineExpose({ dialogRef });
<div v-if="isFetching" class="flex h-40 items-center justify-center">
<Spinner />
</div>
<div
v-else-if="detailsError"
role="alert"
class="flex h-40 flex-col items-center justify-center gap-3 px-4 text-center"
>
<Icon icon="i-lucide-circle-alert" class="size-5 text-n-ruby-10" />
<p class="mb-0 text-sm text-n-slate-11">
{{ $t('CAPTAIN.FAQ_SUGGESTIONS.ERRORS.LOAD_DETAILS') }}
</p>
<Button
:label="$t('CAPTAIN.FAQ_SUGGESTIONS.DETAILS.RETRY')"
variant="faded"
color="slate"
size="sm"
@click="loadDetails"
/>
</div>
<div
v-else-if="!observations.length"
class="flex h-40 items-center justify-center px-4 text-center text-sm text-n-slate-10"
@@ -24,16 +24,6 @@ const isAssistantActive = assistant => {
const fetchDataForRoute = async (routeName, assistantId) => {
const dataFetchMap = {
captain_assistants_responses_index: async () => {
await store.dispatch('captainResponses/get', { assistantId });
await store.dispatch('captainResponses/fetchPendingCount', assistantId);
},
captain_assistants_responses_pending: async () => {
await store.dispatch('captainResponses/get', {
assistantId,
status: 'pending',
});
},
captain_assistants_documents_index: async () => {
await store.dispatch('captainDocuments/get', { assistantId });
},
@@ -503,7 +503,7 @@ const menuItems = computed(() => {
label: t('SIDEBAR.CAPTAIN_RESPONSES'),
activeOn: [
'captain_assistants_responses_index',
'captain_assistants_responses_pending',
'captain_assistants_faq_suggestions',
],
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_assistants_responses_index',
@@ -411,8 +411,8 @@
"DISMISS": "Dismiss"
},
"COVERAGE_BANNER": {
"TEXT": "{count} FAQs are pending review, keeping coverage at {coverage}%. Approve them so your assistant can resolve more on its own.",
"ACTION": "Review FAQs",
"TEXT": "{count} FAQ suggestions are ready for review, keeping coverage at {coverage}%. Approve them so your assistant can resolve more on its own.",
"ACTION": "Review suggestions",
"DISMISS": "Dismiss"
},
"RANGES": {
@@ -457,7 +457,7 @@
"TITLE": "Knowledge coverage",
"COVERAGE": "{pct}% approved",
"APPROVED": "Approved FAQs",
"PENDING": "Pending FAQs",
"SUGGESTIONS": "FAQ suggestions",
"DOCUMENTS": "Documents"
},
"LINKS": {
@@ -1086,7 +1086,6 @@
},
"RESPONSES": {
"HEADER": "FAQs",
"PENDING_FAQS": "Pending FAQs",
"ADD_NEW": "Create new FAQ",
"DOCUMENTABLE": {
"CONVERSATION": "Conversation #{id}"
@@ -1095,12 +1094,10 @@
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"SEARCH_PLACEHOLDER": "Search FAQs...",
"BULK_APPROVE_BUTTON": "Approve",
"BULK_DELETE_BUTTON": "Delete",
"BULK_APPROVE": {
"SUCCESS_MESSAGE": "FAQs approved successfully",
"ERROR_MESSAGE": "There was an error approving the FAQs, please try again."
"ERRORS": {
"LOAD": "There was an error loading FAQs. Please try again."
},
"BULK_DELETE_BUTTON": "Delete",
"BULK_DELETE": {
"TITLE": "Delete FAQs?",
"DESCRIPTION": "Are you sure you want to delete the selected FAQs? This action cannot be undone.",
@@ -1115,18 +1112,7 @@
"SUCCESS_MESSAGE": "FAQ deleted successfully",
"ERROR_MESSAGE": "There was an error deleting the FAQ, please try again."
},
"FILTER": {
"ASSISTANT": "Assistant: {selected}",
"STATUS": "Status: {selected}",
"ALL_ASSISTANTS": "All"
},
"STATUS": {
"TITLE": "Status",
"PENDING": "Pending",
"APPROVED": "Approved",
"ALL": "All"
},
"PENDING_BANNER": {
"SUGGESTIONS_BANNER": {
"TITLE": "Captain grouped recurring customer questions into FAQ suggestions.",
"ACTION": "Review suggestions"
},
@@ -1161,7 +1147,6 @@
},
"EMPTY_STATE": {
"TITLE": "No FAQs Found",
"NO_PENDING_TITLE": "There are no more pending FAQs to review",
"SUBTITLE": "FAQs help your assistant provide quick and accurate answers to questions from your customers. They can be generated automatically from your content or can be added manually.",
"CLEAR_SEARCH": "Clear active filters",
"FEATURE_SPOTLIGHT": {
@@ -1186,7 +1171,8 @@
"DESCRIPTION": "Captain grouped {count} conversation into this suggestion. Review the evidence and refine the FAQ before approval. | Captain grouped {count} conversations into this suggestion. Review the evidence and refine the FAQ before approval.",
"SOURCES": "Source conversations",
"SOURCE_PROGRESS": "{visible} of {total}",
"NO_SOURCES": "No accessible source conversations were found."
"NO_SOURCES": "No accessible source conversations were found.",
"RETRY": "Retry"
},
"EMPTY_STATE": {
"TITLE": "The review queue is clear",
@@ -15,7 +15,7 @@ import AssistantGuidelinesIndex from './assistants/guidelines/Index.vue';
import AssistantScenariosIndex from './assistants/scenarios/Index.vue';
import DocumentsIndex from './documents/Index.vue';
import ResponsesIndex from './responses/Index.vue';
import ResponsesPendingIndex from './responses/Pending.vue';
import FaqSuggestionsIndex from './responses/FaqSuggestions.vue';
import CustomToolsIndex from './tools/Index.vue';
const meta = {
@@ -80,11 +80,21 @@ const assistantRoutes = [
meta,
},
{
path: frontendURL('accounts/:accountId/captain/:assistantId/faqs/pending'),
component: ResponsesPendingIndex,
name: 'captain_assistants_responses_pending',
path: frontendURL(
'accounts/:accountId/captain/:assistantId/faqs/suggestions'
),
component: FaqSuggestionsIndex,
name: 'captain_assistants_faq_suggestions',
meta,
},
{
path: frontendURL('accounts/:accountId/captain/:assistantId/faqs/pending'),
redirect: to => ({
name: 'captain_assistants_faq_suggestions',
params: to.params,
query: to.query,
}),
},
{
path: frontendURL('accounts/:accountId/captain/:assistantId/settings'),
component: AssistantSettingsIndex,
@@ -0,0 +1,129 @@
import { flushPromises, shallowMount } from '@vue/test-utils';
import FaqSuggestions from './FaqSuggestions.vue';
const mocks = vi.hoisted(() => ({
apiGet: vi.fn(),
dispatch: vi.fn(),
replace: vi.fn(),
route: null,
getterValues: null,
}));
vi.mock('dashboard/api/captain/faqSuggestions', () => ({
default: { get: mocks.apiGet },
}));
vi.mock('dashboard/composables/store', async () => {
const { ref } = await import('vue');
mocks.getterValues = {
'captainFaqSuggestions/getRecords': ref([]),
'captainFaqSuggestions/getMeta': ref({ totalCount: 0, page: 1 }),
'captainFaqSuggestions/getUIFlags': ref({
fetchingList: false,
updatingItem: false,
deletingItem: false,
}),
};
return {
useStore: () => ({ dispatch: mocks.dispatch }),
useMapGetter: key => mocks.getterValues[key],
};
});
vi.mock('dashboard/composables', () => ({ useAlert: vi.fn() }));
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: key => key }),
}));
vi.mock('vue-router', async importOriginal => {
const actual = await importOriginal();
const { reactive } = await import('vue');
mocks.route = reactive({
params: { accountId: 1, assistantId: 1 },
query: {},
});
return {
...actual,
useRoute: () => mocks.route,
useRouter: () => ({ replace: mocks.replace }),
};
});
const deferred = () => {
let resolve;
const promise = new Promise(resolvePromise => {
resolve = resolvePromise;
});
return { promise, resolve };
};
describe('FaqSuggestions', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.route.params.assistantId = 1;
mocks.route.query = {};
});
it('keeps the latest assistant results when requests finish in the wrong order', async () => {
const firstRequest = deferred();
const secondRequest = deferred();
mocks.apiGet
.mockReturnValueOnce(firstRequest.promise)
.mockReturnValueOnce(secondRequest.promise);
const wrapper = shallowMount(FaqSuggestions, {
global: {
mocks: { $t: key => key },
},
});
await flushPromises();
mocks.route.params.assistantId = 2;
await flushPromises();
secondRequest.resolve({
data: {
payload: [{ id: 2, question: 'Current assistant' }],
meta: { page: 1, total_count: 1 },
},
});
await flushPromises();
firstRequest.resolve({
data: {
payload: [{ id: 1, question: 'Previous assistant' }],
meta: { page: 1, total_count: 1 },
},
});
await flushPromises();
expect(mocks.apiGet).toHaveBeenNthCalledWith(1, {
page: 1,
search: '',
assistantId: 1,
});
expect(mocks.apiGet).toHaveBeenNthCalledWith(2, {
page: 1,
search: '',
assistantId: 2,
});
expect(mocks.dispatch).toHaveBeenCalledWith(
'captainFaqSuggestions/setRecords',
{
records: [{ id: 2, question: 'Current assistant' }],
meta: { page: 1, total_count: 1 },
}
);
expect(mocks.dispatch).not.toHaveBeenCalledWith(
'captainFaqSuggestions/setRecords',
expect.objectContaining({
records: [{ id: 1, question: 'Previous assistant' }],
})
);
wrapper.unmount();
});
});
@@ -1,11 +1,12 @@
<script setup>
import { computed, nextTick, onMounted, ref } from 'vue';
import { computed, nextTick, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { debounce } from '@chatwoot/utils';
import { useAlert } from 'dashboard/composables';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import CaptainFaqSuggestionsAPI from 'dashboard/api/captain/faqSuggestions';
import Button from 'dashboard/components-next/button/Button.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
@@ -50,16 +51,47 @@ const updateURL = (page, search) => {
router.replace({ query });
};
let suggestionsRequestId = 0;
let fetchingListRequestId = null;
const isCurrentSuggestionRequest = (requestId, assistantId) =>
requestId === suggestionsRequestId &&
assistantId === selectedAssistantId.value;
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 {
await store.dispatch('captainFaqSuggestions/get', {
const response = await CaptainFaqSuggestionsAPI.get({
page,
search: searchQuery.value,
assistantId: selectedAssistantId.value,
assistantId,
});
if (!isCurrentSuggestionRequest(requestId, assistantId)) return [];
const { payload, meta } = response.data;
store.dispatch('captainFaqSuggestions/setRecords', {
records: payload,
meta,
});
return payload;
} catch (error) {
useAlert(error?.message || t('CAPTAIN.FAQ_SUGGESTIONS.ERRORS.LOAD'));
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);
}
}
};
@@ -117,6 +149,11 @@ const handleResolved = () => {
const debouncedSearch = debounce(() => fetchSuggestions(1), 500);
const handleSearchInput = () => {
suggestionsRequestId += 1;
debouncedSearch();
};
const clearFilters = () => {
searchQuery.value = '';
fetchSuggestions(1);
@@ -127,7 +164,24 @@ const initializeFromURL = () => {
fetchSuggestions(parseInt(route.query.page, 10) || 1);
};
onMounted(initializeFromURL);
watch(
selectedAssistantId,
() => {
selectedSuggestion.value = null;
activeSuggestionId.value = null;
store.dispatch('captainFaqSuggestions/setRecords', {
records: [],
meta: { page: 1, total_count: 0 },
});
initializeFromURL();
},
{ immediate: true }
);
onUnmounted(() => {
suggestionsRequestId += 1;
store.dispatch('captainFaqSuggestions/setFetchingList', false);
});
</script>
<template>
@@ -151,7 +205,7 @@ onMounted(initializeFromURL);
size="sm"
type="search"
autofocus
@input="debouncedSearch"
@input="handleSearchInput"
/>
</template>
@@ -1,11 +1,13 @@
<script setup>
import { computed, onMounted, ref, nextTick } from 'vue';
import { computed, onUnmounted, ref, nextTick, watch } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { useRouter, useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { debounce } from '@chatwoot/utils';
import { useAccount } from 'dashboard/composables/useAccount';
import CaptainResponseAPI from 'dashboard/api/captain/response';
import Banner from 'dashboard/components-next/banner/Banner.vue';
import Input from 'dashboard/components-next/input/Input.vue';
@@ -95,11 +97,20 @@ const updateURLWithFilters = (page, search) => {
router.replace({ query });
};
const fetchResponses = (page = 1) => {
const filterParams = { page, status: 'approved' };
let responsesRequestId = 0;
let fetchingListRequestId = null;
if (selectedAssistantId.value) {
filterParams.assistantId = selectedAssistantId.value;
const isCurrentResponseRequest = (requestId, assistantId) =>
requestId === responsesRequestId && assistantId === selectedAssistantId.value;
const fetchResponses = async (page = 1) => {
responsesRequestId += 1;
const requestId = responsesRequestId;
const assistantId = selectedAssistantId.value;
const filterParams = { page };
if (assistantId) {
filterParams.assistantId = assistantId;
}
if (searchQuery.value) {
filterParams.search = searchQuery.value;
@@ -108,7 +119,31 @@ const fetchResponses = (page = 1) => {
// Update URL with current filters
updateURLWithFilters(page, searchQuery.value);
store.dispatch('captainResponses/get', filterParams);
fetchingListRequestId = requestId;
store.dispatch('captainResponses/setFetchingList', true);
try {
const response = await CaptainResponseAPI.get(filterParams);
if (!isCurrentResponseRequest(requestId, assistantId)) return [];
const { payload, meta } = response.data;
store.dispatch('captainResponses/setRecords', {
records: payload,
meta,
});
return payload;
} 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);
}
}
};
// Bulk action
@@ -181,24 +216,48 @@ const debouncedSearch = debounce(async () => {
fetchResponses(1);
}, 500);
const handleSearchInput = () => {
responsesRequestId += 1;
debouncedSearch();
};
const initializeFromURL = () => {
if (route.query.search) {
searchQuery.value = route.query.search;
}
searchQuery.value = route.query.search || '';
const pageFromURL = parseInt(route.query.page, 10) || 1;
fetchResponses(pageFromURL);
};
const navigateToPendingFAQs = () => {
router.push({ name: 'captain_assistants_responses_pending' });
const navigateToFaqSuggestions = () => {
router.push({
name: 'captain_assistants_faq_suggestions',
params: {
accountId: route.params.accountId,
assistantId: selectedAssistantId.value,
},
});
};
onMounted(() => {
initializeFromURL();
store.dispatch(
'captainFaqSuggestions/fetchOpenCount',
selectedAssistantId.value
);
watch(
selectedAssistantId,
() => {
selectedResponse.value = null;
bulkSelectedIds.value = new Set();
store.dispatch('captainResponses/setRecords', {
records: [],
meta: { page: 1, total_count: 0 },
});
initializeFromURL();
store.dispatch(
'captainFaqSuggestions/fetchOpenCount',
selectedAssistantId.value
);
},
{ immediate: true }
);
onUnmounted(() => {
responsesRequestId += 1;
store.dispatch('captainResponses/setFetchingList', false);
});
</script>
@@ -240,7 +299,7 @@ onMounted(() => {
size="sm"
type="search"
autofocus
@input="debouncedSearch"
@input="handleSearchInput"
/>
</div>
</template>
@@ -274,10 +333,10 @@ onMounted(() => {
v-if="suggestionCount > 0"
color="blue"
class="mb-4 -mt-3"
:action-label="$t('CAPTAIN.RESPONSES.PENDING_BANNER.ACTION')"
@action="navigateToPendingFAQs"
:action-label="$t('CAPTAIN.RESPONSES.SUGGESTIONS_BANNER.ACTION')"
@action="navigateToFaqSuggestions"
>
{{ $t('CAPTAIN.RESPONSES.PENDING_BANNER.TITLE') }}
{{ $t('CAPTAIN.RESPONSES.SUGGESTIONS_BANNER.TITLE') }}
</Banner>
<div class="flex flex-col gap-4">
@@ -48,20 +48,6 @@ export default createStore({
return response;
},
handleBulkApprove: async function handleBulkApprove({ dispatch }, ids) {
const response = await dispatch('processBulkAction', {
type: 'AssistantResponse',
actionType: 'approve',
ids,
});
// Update response store after successful API call
await dispatch('captainResponses/updateBulkResponses', response, {
root: true,
});
return response;
},
handleBulkSync: async function handleBulkSync({ dispatch }, { ids }) {
const response = await dispatch('processBulkAction', {
type: 'AssistantDocument',
@@ -3,6 +3,7 @@ import { createStore } from '../storeFactory';
import { throwErrorMessage } from 'dashboard/store/utils/api';
const SET_OPEN_COUNT = 'SET_OPEN_COUNT';
let openCountRequestId = 0;
export default createStore({
name: 'CaptainFaqSuggestion',
@@ -20,6 +21,13 @@ export default createStore({
},
},
actions: mutations => ({
setFetchingList({ commit }, isFetching) {
commit(mutations.SET_UI_FLAG, { fetchingList: isFetching });
},
setRecords({ commit }, { records, meta }) {
commit(mutations.SET, records);
commit(mutations.SET_META, meta);
},
approve: async ({ commit }, { id, question, answer }) => {
commit(mutations.SET_UI_FLAG, { updatingItem: true });
try {
@@ -48,14 +56,20 @@ export default createStore({
}
},
fetchOpenCount: async ({ commit }, assistantId) => {
openCountRequestId += 1;
const requestId = openCountRequestId;
commit(SET_OPEN_COUNT, 0);
try {
const response = await CaptainFaqSuggestionsAPI.get({
assistantId,
page: 1,
});
if (requestId !== openCountRequestId) return;
commit(SET_OPEN_COUNT, response.data?.meta?.total_count || 0);
} catch (error) {
commit(SET_OPEN_COUNT, 0);
} catch {
if (requestId === openCountRequestId) commit(SET_OPEN_COUNT, 0);
}
},
}),
@@ -0,0 +1,47 @@
import CaptainFaqSuggestionsAPI from 'dashboard/api/captain/faqSuggestions';
import faqSuggestions from './faqSuggestions';
vi.mock('dashboard/api/captain/faqSuggestions', () => ({
default: {
get: vi.fn(),
approve: vi.fn(),
dismiss: vi.fn(),
},
}));
const deferred = () => {
let resolve;
const promise = new Promise(resolvePromise => {
resolve = resolvePromise;
});
return { promise, resolve };
};
describe('captainFaqSuggestions', () => {
it('keeps the latest assistant count when requests finish in the wrong order', async () => {
const firstRequest = deferred();
const secondRequest = deferred();
CaptainFaqSuggestionsAPI.get
.mockReturnValueOnce(firstRequest.promise)
.mockReturnValueOnce(secondRequest.promise);
const commit = vi.fn();
const firstAction = faqSuggestions.actions.fetchOpenCount({ commit }, 1);
const secondAction = faqSuggestions.actions.fetchOpenCount({ commit }, 2);
secondRequest.resolve({ data: { meta: { total_count: 4 } } });
await secondAction;
firstRequest.resolve({ data: { meta: { total_count: 9 } } });
await firstAction;
expect(CaptainFaqSuggestionsAPI.get).toHaveBeenNthCalledWith(1, {
assistantId: 1,
page: 1,
});
expect(CaptainFaqSuggestionsAPI.get).toHaveBeenNthCalledWith(2, {
assistantId: 2,
page: 1,
});
expect(commit).toHaveBeenLastCalledWith('SET_OPEN_COUNT', 4);
});
});
@@ -1,58 +1,22 @@
import CaptainResponseAPI from 'dashboard/api/captain/response';
import { createStore } from '../storeFactory';
const SET_PENDING_COUNT = 'SET_PENDING_COUNT';
export default createStore({
name: 'CaptainResponse',
API: CaptainResponseAPI,
getters: {
getPendingCount: state => state.meta.pendingCount || 0,
},
mutations: {
[SET_PENDING_COUNT](state, count) {
state.meta = {
...state.meta,
pendingCount: Number(count),
};
},
},
actions: mutations => ({
setFetchingList({ commit }, isFetching) {
commit(mutations.SET_UI_FLAG, { fetchingList: isFetching });
},
setRecords({ commit }, { records, meta }) {
commit(mutations.SET, records);
commit(mutations.SET_META, meta);
},
removeBulkResponses: ({ commit, state }, ids) => {
const updatedRecords = state.records.filter(
record => !ids.includes(record.id)
);
commit(mutations.SET, updatedRecords);
},
updateBulkResponses: ({ commit, state }, approvedResponses) => {
// Create a map of updated responses for faster lookup
const updatedResponsesMap = approvedResponses.reduce((map, response) => {
map[response.id] = response;
return map;
}, {});
// Update existing records with updated data
const updatedRecords = state.records.map(record => {
if (updatedResponsesMap[record.id]) {
return updatedResponsesMap[record.id]; // Replace with the updated response
}
return record;
});
commit(mutations.SET, updatedRecords);
},
fetchPendingCount: async ({ commit }, assistantId) => {
try {
const response = await CaptainResponseAPI.get({
status: 'pending',
page: 1,
assistantId,
});
const count = response.data?.meta?.total_count || 0;
commit(SET_PENDING_COUNT, count);
} catch (error) {
commit(SET_PENDING_COUNT, 0);
}
},
}),
});