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
@@ -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',