fix(captain): clean up FAQ suggestion review flow
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+5
-39
@@ -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>
|
||||
|
||||
+14
-9
@@ -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>
|
||||
|
||||
+10
-5
@@ -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',
|
||||
|
||||
+74
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
+21
@@ -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"
|
||||
|
||||
-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();
|
||||
});
|
||||
});
|
||||
+60
-6
@@ -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);
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user