feat(captain): add FAQ suggestion review interface (4/4) (#15017)
Captain now groups recurring questions from resolved conversations into FAQ suggestions and orders them by the number of source conversations. Agents can view suggestions and open source conversations they can access. Administrators can edit, approve, or dismiss suggestions. The old pending FAQ flow is removed. The Captain overview and FAQ page now use open suggestion counts and link to the same review page. Approved FAQs remain unchanged. ## Depends on #14979 ## Closes https://linear.app/chatwoot/issue/CW-7496/fe-and-ux ## How to test 1. Open Captain and choose an assistant with open FAQ suggestions. 2. Open FAQ suggestions from the overview or the FAQ banner. Confirm that suggestions are ordered by conversation count. 3. Switch assistants without leaving the page. Confirm that the previous results clear and the new assistant results load. 4. Search for suggestions and move between pages. Change the search or page again before the first request finishes, and confirm that the latest request controls the results and loading state. 5. Open a suggestion and review its source conversations. 6. Make the source conversation request fail. Confirm that the dialog keeps the error visible and that Retry loads the sources. 7. Sign in as an agent. Confirm that you can read suggestions and source conversations you can access, but cannot edit, approve, or dismiss suggestions. 8. Sign in as an administrator. Edit and save a suggestion, approve one suggestion, and dismiss another. 9. Confirm that the approved suggestion appears in the assistant FAQ list. 10. Open the old pending FAQ URL and confirm that it redirects to FAQ suggestions. ## What changed 1. Added the FAQ suggestion list, cards, search, pagination, and empty state. 2. Added a review dialog with source conversation links, a clear error message, and a Retry button. 3. Added edit, approve, and dismiss actions for administrators. 4. Removed the old pending FAQ status, count, page, and bulk approval action. 5. Updated the Captain overview and FAQ banner to use open suggestion counts and link to FAQ suggestions. 6. Made each FAQ page load data for the selected assistant and ignore results from older requests. 7. Kept the old pending FAQ URL as a redirect so saved links continue to work. --------- Co-authored-by: Sony Mathew <sony@chatwoot.com> Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: iamsivin <iamsivin@gmail.com>
This commit is contained in:
40
app/javascript/dashboard/api/captain/faqSuggestions.js
Normal file
40
app/javascript/dashboard/api/captain/faqSuggestions.js
Normal file
@@ -0,0 +1,40 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class CaptainFaqSuggestions extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/faq_suggestions', { accountScoped: true });
|
||||
}
|
||||
|
||||
show(id, { signal } = {}) {
|
||||
return axios.get(`${this.url}/${id}`, { signal });
|
||||
}
|
||||
|
||||
get({ page = 1, search, assistantId, status = 'open', signal } = {}) {
|
||||
return axios.get(this.url, {
|
||||
params: {
|
||||
page,
|
||||
search,
|
||||
assistant_id: assistantId,
|
||||
status,
|
||||
},
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
update(id, data) {
|
||||
return axios.patch(`${this.url}/${id}`, { faq_suggestion: data });
|
||||
}
|
||||
|
||||
approve(id, data) {
|
||||
return axios.post(`${this.url}/${id}/approve`, {
|
||||
faq_suggestion: data,
|
||||
});
|
||||
}
|
||||
|
||||
dismiss(id) {
|
||||
return axios.post(`${this.url}/${id}/dismiss`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainFaqSuggestions();
|
||||
@@ -6,15 +6,15 @@ class CaptainResponses extends ApiClient {
|
||||
super('captain/assistant_responses', { accountScoped: true });
|
||||
}
|
||||
|
||||
get({ page = 1, search, assistantId, documentId, status } = {}) {
|
||||
get({ page = 1, search, assistantId, documentId, signal } = {}) {
|
||||
return axios.get(this.url, {
|
||||
params: {
|
||||
page,
|
||||
search,
|
||||
assistant_id: assistantId,
|
||||
document_id: documentId,
|
||||
status,
|
||||
},
|
||||
signal,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
suggestion: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['approve', 'dismiss', 'review']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const sourceLabel = computed(() =>
|
||||
t('CAPTAIN.FAQ_SUGGESTIONS.SOURCE_COUNT', {
|
||||
count: props.suggestion.source_count,
|
||||
})
|
||||
);
|
||||
|
||||
const updatedAt = computed(() =>
|
||||
dynamicTime(props.suggestion.updated_at || props.suggestion.created_at)
|
||||
);
|
||||
|
||||
const language = computed(() =>
|
||||
props.suggestion.language?.replace('_', '-').toUpperCase()
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout>
|
||||
<div
|
||||
class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="mb-2 flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-n-brand/10 px-2.5 py-1 text-xs font-medium text-n-blue-11"
|
||||
>
|
||||
<Icon icon="i-lucide-messages-square" class="size-3.5" />
|
||||
{{ sourceLabel }}
|
||||
</span>
|
||||
<span
|
||||
v-if="language"
|
||||
class="rounded-full bg-n-alpha-2 px-2.5 py-1 text-xs font-medium text-n-slate-11"
|
||||
>
|
||||
{{ language }}
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="text-base font-medium leading-6 text-n-slate-12">
|
||||
{{ suggestion.question }}
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
:label="$t('CAPTAIN.FAQ_SUGGESTIONS.REVIEW')"
|
||||
icon="i-lucide-list-collapse"
|
||||
size="sm"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
class="shrink-0"
|
||||
@click="emit('review', suggestion)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="line-clamp-2 text-sm leading-5 text-n-slate-11">
|
||||
{{ suggestion.answer }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-3 border-t border-n-weak pt-2.5 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div
|
||||
class="flex min-w-0 flex-wrap items-center gap-x-4 gap-y-2 text-xs text-n-slate-10"
|
||||
>
|
||||
<span class="inline-flex min-w-0 items-center gap-1.5">
|
||||
<Icon icon="i-woot-captain" class="size-3.5 shrink-0" />
|
||||
<span class="truncate">{{ suggestion.assistant?.name }}</span>
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<Icon icon="i-lucide-clock-3" class="size-3.5" />
|
||||
{{ updatedAt }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
:label="$t('CAPTAIN.FAQ_SUGGESTIONS.DISMISS')"
|
||||
icon="i-lucide-circle-x"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
:disabled="isLoading"
|
||||
@click="emit('dismiss', suggestion)"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('CAPTAIN.FAQ_SUGGESTIONS.APPROVE')"
|
||||
icon="i-lucide-circle-check-big"
|
||||
size="sm"
|
||||
:is-loading="isLoading"
|
||||
:disabled="isLoading"
|
||||
@click="emit('approve', suggestion)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardLayout>
|
||||
</template>
|
||||
@@ -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,232 @@
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import FaqSuggestionReviewDialog from './FaqSuggestionReviewDialog.vue';
|
||||
|
||||
const { dispatch, push, show, uiFlags } = vi.hoisted(() => ({
|
||||
dispatch: vi.fn(),
|
||||
push: vi.fn(),
|
||||
show: vi.fn(),
|
||||
uiFlags: {
|
||||
value: {
|
||||
fetchingItem: false,
|
||||
updatingItem: false,
|
||||
deletingItem: false,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables/store', () => ({
|
||||
useStore: () => ({ dispatch }),
|
||||
useMapGetter: () => uiFlags,
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/api/captain/faqSuggestions', () => ({
|
||||
default: { show },
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables', () => ({ useAlert: vi.fn() }));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: key => key }),
|
||||
}));
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push }),
|
||||
}));
|
||||
|
||||
const DialogStub = {
|
||||
methods: { close: vi.fn() },
|
||||
template: `
|
||||
<div>
|
||||
<slot name="description" />
|
||||
<slot />
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
|
||||
describe('FaqSuggestionReviewDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
uiFlags.value.updatingItem = false;
|
||||
uiFlags.value.deletingItem = false;
|
||||
});
|
||||
|
||||
it('keeps a visible error when source conversations fail to load', async () => {
|
||||
show.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'
|
||||
);
|
||||
});
|
||||
|
||||
it('opens source conversations using their display ID', async () => {
|
||||
show.mockResolvedValueOnce({
|
||||
data: {
|
||||
observations: [
|
||||
{
|
||||
id: 1,
|
||||
generated_question: 'How do I enable the feature?',
|
||||
created_at: 1,
|
||||
conversation: { id: 99, display_id: 42 },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = shallowMount(FaqSuggestionReviewDialog, {
|
||||
props: {
|
||||
suggestion: {
|
||||
id: 1,
|
||||
question: 'How do I enable the feature?',
|
||||
answer: 'Turn it on in settings.',
|
||||
source_count: 1,
|
||||
assistant: { name: 'Support assistant' },
|
||||
language: 'en',
|
||||
},
|
||||
},
|
||||
global: {
|
||||
mocks: { $t: key => key },
|
||||
stubs: { Dialog: DialogStub },
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
await wrapper.get('section button').trigger('click');
|
||||
|
||||
expect(push).toHaveBeenCalledWith({
|
||||
name: 'inbox_conversation',
|
||||
params: { conversation_id: 42 },
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the review actions to users who can open the page', async () => {
|
||||
show.mockResolvedValueOnce({ data: { observations: [] } });
|
||||
|
||||
const wrapper = shallowMount(FaqSuggestionReviewDialog, {
|
||||
props: {
|
||||
suggestion: {
|
||||
id: 1,
|
||||
question: 'How do I enable the feature?',
|
||||
answer: 'Turn it on in settings.',
|
||||
source_count: 1,
|
||||
assistant: { name: 'Support assistant' },
|
||||
language: 'en',
|
||||
},
|
||||
},
|
||||
global: {
|
||||
mocks: { $t: key => key },
|
||||
stubs: { Dialog: DialogStub },
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(
|
||||
wrapper.findAllComponents(Button).map(button => button.props('label'))
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
'CAPTAIN.FAQ_SUGGESTIONS.DISMISS',
|
||||
'CAPTAIN.FAQ_SUGGESTIONS.SAVE',
|
||||
'CAPTAIN.FAQ_SUGGESTIONS.APPROVE_FAQ',
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('disables review actions while source conversations are loading', async () => {
|
||||
let resolveRequest;
|
||||
show.mockReturnValueOnce(
|
||||
new Promise(resolve => {
|
||||
resolveRequest = resolve;
|
||||
})
|
||||
);
|
||||
|
||||
const wrapper = shallowMount(FaqSuggestionReviewDialog, {
|
||||
props: {
|
||||
suggestion: {
|
||||
id: 1,
|
||||
question: 'How do I enable the feature?',
|
||||
answer: 'Turn it on in settings.',
|
||||
source_count: 1,
|
||||
assistant: { name: 'Support assistant' },
|
||||
language: 'en',
|
||||
},
|
||||
},
|
||||
global: {
|
||||
mocks: { $t: key => key },
|
||||
stubs: { Dialog: DialogStub },
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const actionButtons = wrapper
|
||||
.findAllComponents(Button)
|
||||
.filter(button =>
|
||||
[
|
||||
'CAPTAIN.FAQ_SUGGESTIONS.DISMISS',
|
||||
'CAPTAIN.FAQ_SUGGESTIONS.SAVE',
|
||||
'CAPTAIN.FAQ_SUGGESTIONS.APPROVE_FAQ',
|
||||
].includes(button.props('label'))
|
||||
);
|
||||
|
||||
expect(actionButtons).toHaveLength(3);
|
||||
expect(
|
||||
actionButtons.map(button => ({
|
||||
label: button.props('label'),
|
||||
disabled: button.attributes('disabled'),
|
||||
}))
|
||||
).toEqual([
|
||||
{
|
||||
label: 'CAPTAIN.FAQ_SUGGESTIONS.DISMISS',
|
||||
disabled: 'true',
|
||||
},
|
||||
{
|
||||
label: 'CAPTAIN.FAQ_SUGGESTIONS.SAVE',
|
||||
disabled: 'true',
|
||||
},
|
||||
{
|
||||
label: 'CAPTAIN.FAQ_SUGGESTIONS.APPROVE_FAQ',
|
||||
disabled: 'true',
|
||||
},
|
||||
]);
|
||||
|
||||
resolveRequest({ data: { observations: [] } });
|
||||
await flushPromises();
|
||||
|
||||
const enabledActionButtons = wrapper
|
||||
.findAllComponents(Button)
|
||||
.filter(button =>
|
||||
[
|
||||
'CAPTAIN.FAQ_SUGGESTIONS.DISMISS',
|
||||
'CAPTAIN.FAQ_SUGGESTIONS.SAVE',
|
||||
'CAPTAIN.FAQ_SUGGESTIONS.APPROVE_FAQ',
|
||||
].includes(button.props('label'))
|
||||
);
|
||||
|
||||
expect(
|
||||
enabledActionButtons.map(button => button.attributes('disabled'))
|
||||
).toEqual(['false', 'false', 'false']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useAbortableRequest } from 'dashboard/composables/useAbortableRequest';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
import CaptainFaqSuggestionsAPI from 'dashboard/api/captain/faqSuggestions';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
|
||||
const props = defineProps({
|
||||
suggestion: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'resolved']);
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const dialogRef = ref(null);
|
||||
const details = ref(null);
|
||||
const detailsError = ref(false);
|
||||
|
||||
const uiFlags = useMapGetter('captainFaqSuggestions/getUIFlags');
|
||||
const isSaving = computed(() => uiFlags.value.updatingItem);
|
||||
const isDismissing = computed(() => uiFlags.value.deletingItem);
|
||||
const { run: runDetailsRequest, isPending: isFetching } = useAbortableRequest();
|
||||
|
||||
const state = reactive({
|
||||
question: props.suggestion.question,
|
||||
answer: props.suggestion.answer,
|
||||
});
|
||||
|
||||
const observations = computed(() => details.value?.observations || []);
|
||||
const isInvalid = computed(
|
||||
() => !state.question.trim() || !state.answer.trim()
|
||||
);
|
||||
|
||||
const loadDetails = async () => {
|
||||
detailsError.value = false;
|
||||
|
||||
try {
|
||||
const response = await runDetailsRequest(signal =>
|
||||
CaptainFaqSuggestionsAPI.show(props.suggestion.id, { signal })
|
||||
);
|
||||
|
||||
if (response) details.value = response.data;
|
||||
} catch (error) {
|
||||
detailsError.value = true;
|
||||
useAlert(
|
||||
error?.message || t('CAPTAIN.FAQ_SUGGESTIONS.ERRORS.LOAD_DETAILS')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => dialogRef.value.close();
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const updatedSuggestion = await store.dispatch(
|
||||
'captainFaqSuggestions/update',
|
||||
{
|
||||
id: props.suggestion.id,
|
||||
question: state.question.trim(),
|
||||
answer: state.answer.trim(),
|
||||
}
|
||||
);
|
||||
details.value = { ...details.value, ...updatedSuggestion };
|
||||
useAlert(t('CAPTAIN.FAQ_SUGGESTIONS.SUCCESS.SAVED'));
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t('CAPTAIN.FAQ_SUGGESTIONS.ERRORS.SAVE'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleApprove = async () => {
|
||||
try {
|
||||
await store.dispatch('captainFaqSuggestions/approve', {
|
||||
id: props.suggestion.id,
|
||||
question: state.question.trim(),
|
||||
answer: state.answer.trim(),
|
||||
});
|
||||
useAlert(t('CAPTAIN.FAQ_SUGGESTIONS.SUCCESS.APPROVED'));
|
||||
emit('resolved', { id: props.suggestion.id, action: 'approved' });
|
||||
close();
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t('CAPTAIN.FAQ_SUGGESTIONS.ERRORS.APPROVE'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismiss = async () => {
|
||||
try {
|
||||
await store.dispatch('captainFaqSuggestions/dismiss', props.suggestion.id);
|
||||
useAlert(t('CAPTAIN.FAQ_SUGGESTIONS.SUCCESS.DISMISSED'));
|
||||
emit('resolved', { id: props.suggestion.id, action: 'dismissed' });
|
||||
close();
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t('CAPTAIN.FAQ_SUGGESTIONS.ERRORS.DISMISS'));
|
||||
}
|
||||
};
|
||||
|
||||
const openConversation = conversationId => {
|
||||
close();
|
||||
router.push({
|
||||
name: 'inbox_conversation',
|
||||
params: { conversation_id: conversationId },
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(loadDetails);
|
||||
|
||||
defineExpose({ dialogRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
width="3xl"
|
||||
position="top"
|
||||
overflow-y-auto
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-base font-medium leading-6 text-n-slate-12">
|
||||
{{ $t('CAPTAIN.FAQ_SUGGESTIONS.DETAILS.TITLE') }}
|
||||
</h3>
|
||||
<p class="mb-0 text-sm text-n-slate-11">
|
||||
{{
|
||||
$t('CAPTAIN.FAQ_SUGGESTIONS.DETAILS.DESCRIPTION', {
|
||||
count: suggestion.source_count,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
icon="i-lucide-x"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="sm"
|
||||
class="-me-1.5 -mt-1.5 shrink-0"
|
||||
:aria-label="$t('DIALOG.BUTTONS.CANCEL')"
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid min-h-0 grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_18rem]"
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-4">
|
||||
<Input
|
||||
v-model="state.question"
|
||||
:label="$t('CAPTAIN.RESPONSES.FORM.QUESTION.LABEL')"
|
||||
:placeholder="$t('CAPTAIN.RESPONSES.FORM.QUESTION.PLACEHOLDER')"
|
||||
/>
|
||||
<TextArea
|
||||
v-model="state.answer"
|
||||
:label="$t('CAPTAIN.RESPONSES.FORM.ANSWER.LABEL')"
|
||||
:placeholder="$t('CAPTAIN.RESPONSES.FORM.ANSWER.PLACEHOLDER')"
|
||||
auto-height
|
||||
resize
|
||||
min-height="10rem"
|
||||
max-height="20rem"
|
||||
/>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-n-slate-10">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-n-alpha-2 px-2.5 py-1"
|
||||
>
|
||||
<Icon icon="i-lucide-languages" class="size-3.5" />
|
||||
{{ suggestion.language?.replace('_', '-').toUpperCase() }}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-n-alpha-2 px-2.5 py-1"
|
||||
>
|
||||
<Icon icon="i-woot-captain" class="size-3.5" />
|
||||
{{ suggestion.assistant?.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="min-h-0 rounded-xl border border-n-weak bg-n-alpha-2 p-3">
|
||||
<div class="mb-3 flex items-center justify-between gap-2 px-1">
|
||||
<h4 class="text-sm font-medium text-n-slate-12">
|
||||
{{ $t('CAPTAIN.FAQ_SUGGESTIONS.DETAILS.SOURCES') }}
|
||||
</h4>
|
||||
<span class="text-xs tabular-nums text-n-slate-10">
|
||||
{{
|
||||
$t('CAPTAIN.FAQ_SUGGESTIONS.DETAILS.SOURCE_PROGRESS', {
|
||||
visible: observations.length,
|
||||
total: suggestion.source_count,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isFetching"
|
||||
class="flex max-h-96 flex-col gap-2 overflow-hidden"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div
|
||||
v-for="n in Math.min(suggestion.source_count, 6)"
|
||||
:key="n"
|
||||
class="flex w-full animate-pulse flex-col gap-1.5 rounded-lg outline outline-1 -outline-offset-1 outline-n-weak bg-n-solid-2 p-3"
|
||||
>
|
||||
<span class="flex items-center justify-between gap-2">
|
||||
<span class="text-xs font-medium">
|
||||
<span
|
||||
class="inline-block h-2 w-24 rounded-sm bg-n-alpha-2 align-middle"
|
||||
/>
|
||||
</span>
|
||||
<span class="size-3.5 shrink-0 rounded-sm bg-n-alpha-2" />
|
||||
</span>
|
||||
<span class="flex min-h-[2.5rem] flex-col text-xs leading-5">
|
||||
<span class="h-2 w-full rounded-sm bg-n-alpha-2" />
|
||||
<span class="mt-2 h-2 w-4/5 rounded-sm bg-n-alpha-2" />
|
||||
</span>
|
||||
<span class="text-xs">
|
||||
<span
|
||||
class="inline-block h-2 w-16 rounded-sm bg-n-alpha-2 align-middle"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="detailsError"
|
||||
role="alert"
|
||||
class="flex min-h-[10rem] 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 min-h-[10rem] items-center justify-center px-4 text-center text-sm text-n-slate-10"
|
||||
>
|
||||
{{ $t('CAPTAIN.FAQ_SUGGESTIONS.DETAILS.NO_SOURCES') }}
|
||||
</div>
|
||||
<div v-else class="flex max-h-96 flex-col gap-2 overflow-y-auto">
|
||||
<button
|
||||
v-for="observation in observations"
|
||||
:key="observation.id"
|
||||
type="button"
|
||||
class="flex w-full flex-col gap-1.5 rounded-lg border border-n-weak bg-n-solid-2 p-3 text-start transition-colors hover:border-n-brand"
|
||||
@click="openConversation(observation.conversation.display_id)"
|
||||
>
|
||||
<span class="flex items-center justify-between gap-2">
|
||||
<span class="text-xs font-medium text-n-blue-11">
|
||||
{{
|
||||
$t('CAPTAIN.RESPONSES.DOCUMENTABLE.CONVERSATION', {
|
||||
id: observation.conversation.display_id,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<Icon
|
||||
icon="i-lucide-arrow-up-right"
|
||||
class="size-3.5 text-n-slate-10"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="line-clamp-2 min-h-[2.5rem] text-xs leading-5 text-n-slate-11"
|
||||
>
|
||||
{{ observation.generated_question }}
|
||||
</span>
|
||||
<span class="text-xs text-n-slate-10">
|
||||
{{ dynamicTime(observation.created_at) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div
|
||||
class="flex w-full flex-col-reverse gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<Button
|
||||
:label="$t('CAPTAIN.FAQ_SUGGESTIONS.DISMISS')"
|
||||
icon="i-lucide-circle-x"
|
||||
variant="ghost"
|
||||
color="ruby"
|
||||
:is-loading="isDismissing"
|
||||
:disabled="isFetching || isSaving || isDismissing"
|
||||
@click="handleDismiss"
|
||||
/>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
:label="$t('CAPTAIN.FAQ_SUGGESTIONS.SAVE')"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:is-loading="isSaving"
|
||||
:disabled="isFetching || isInvalid || isSaving || isDismissing"
|
||||
@click="handleSave"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('CAPTAIN.FAQ_SUGGESTIONS.APPROVE_FAQ')"
|
||||
icon="i-lucide-circle-check-big"
|
||||
:is-loading="isSaving"
|
||||
:disabled="isFetching || isInvalid || isSaving || isDismissing"
|
||||
@click="handleApprove"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -511,7 +511,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,20 +1112,9 @@
|
||||
"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": {
|
||||
"TITLE": "Captain has found some FAQs your customers were looking for.",
|
||||
"ACTION": "Click here to review"
|
||||
"SUGGESTIONS_BANNER": {
|
||||
"TITLE": "Captain grouped recurring customer questions into FAQ suggestions.",
|
||||
"ACTION": "Review suggestions"
|
||||
},
|
||||
"FORM_DESCRIPTION": "Add a question and its corresponding answer to the knowledge base and select the assistant it should be associated with.",
|
||||
"CREATE": {
|
||||
@@ -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": {
|
||||
@@ -1170,6 +1155,43 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"FAQ_SUGGESTIONS": {
|
||||
"HEADER": "FAQ suggestions",
|
||||
"SEARCH_PLACEHOLDER": "Search suggestions...",
|
||||
"QUEUE_COUNT": "{count} suggestion to review | {count} suggestions to review",
|
||||
"QUEUE_HINT": "Ranked by customer demand",
|
||||
"SOURCE_COUNT": "{count} conversation | {count} conversations",
|
||||
"REVIEW": "Review sources",
|
||||
"APPROVE": "Approve",
|
||||
"APPROVE_FAQ": "Approve FAQ",
|
||||
"DISMISS": "Dismiss",
|
||||
"SAVE": "Save changes",
|
||||
"DETAILS": {
|
||||
"TITLE": "Review FAQ suggestion",
|
||||
"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.",
|
||||
"RETRY": "Retry"
|
||||
},
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "The review queue is clear",
|
||||
"SUBTITLE": "New FAQ suggestions will appear here when they are ready to review.",
|
||||
"CLEAR_SEARCH": "Clear search"
|
||||
},
|
||||
"SUCCESS": {
|
||||
"APPROVED": "FAQ approved and added to Captain's knowledge.",
|
||||
"DISMISSED": "Suggestion dismissed.",
|
||||
"SAVED": "Suggestion updated."
|
||||
},
|
||||
"ERRORS": {
|
||||
"LOAD": "There was an error loading FAQ suggestions. Please try again.",
|
||||
"LOAD_DETAILS": "There was an error loading the source conversations.",
|
||||
"APPROVE": "There was an error approving the FAQ. Please try again.",
|
||||
"DISMISS": "There was an error dismissing the suggestion. Please try again.",
|
||||
"SAVE": "There was an error saving the suggestion. Please try again."
|
||||
}
|
||||
},
|
||||
"INBOXES": {
|
||||
"HEADER": "Connected Inboxes",
|
||||
"ADD_NEW": "Connect a new inbox",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
|
||||
import {
|
||||
CONVERSATION_PERMISSIONS,
|
||||
ROLES,
|
||||
} from 'dashboard/constants/permissions';
|
||||
import { frontendURL } from '../../../helper/URLHelper';
|
||||
|
||||
import CaptainPageRouteView from './pages/CaptainPageRouteView.vue';
|
||||
@@ -15,7 +19,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 = {
|
||||
@@ -24,6 +28,11 @@ const meta = {
|
||||
installationTypes: [INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE],
|
||||
};
|
||||
|
||||
const faqSuggestionsMeta = {
|
||||
...meta,
|
||||
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
||||
};
|
||||
|
||||
const metaCustomTools = {
|
||||
permissions: ['administrator', 'agent'],
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS,
|
||||
@@ -79,11 +88,21 @@ const assistantRoutes = [
|
||||
name: 'captain_assistants_inboxes_index',
|
||||
meta,
|
||||
},
|
||||
{
|
||||
path: frontendURL(
|
||||
'accounts/:accountId/captain/:assistantId/faqs/suggestions'
|
||||
),
|
||||
component: FaqSuggestionsIndex,
|
||||
name: 'captain_assistants_faq_suggestions',
|
||||
meta: faqSuggestionsMeta,
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/faqs/pending'),
|
||||
component: ResponsesPendingIndex,
|
||||
name: 'captain_assistants_responses_pending',
|
||||
meta,
|
||||
redirect: to => ({
|
||||
name: 'captain_assistants_faq_suggestions',
|
||||
params: to.params,
|
||||
query: to.query,
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/settings'),
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
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;
|
||||
let reject;
|
||||
const promise = new Promise((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
const PageLayoutStub = {
|
||||
template: '<div><slot name="body" /></div>',
|
||||
};
|
||||
|
||||
const FaqSuggestionCardStub = {
|
||||
props: ['suggestion'],
|
||||
template: '<div>{{ suggestion.question }}</div>',
|
||||
};
|
||||
|
||||
describe('FaqSuggestions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.route.params.assistantId = 1;
|
||||
mocks.route.query = {};
|
||||
mocks.getterValues['captainFaqSuggestions/getRecords'].value = [];
|
||||
mocks.getterValues['captainFaqSuggestions/getMeta'].value = {
|
||||
totalCount: 0,
|
||||
page: 1,
|
||||
};
|
||||
mocks.dispatch.mockImplementation((action, payload) => {
|
||||
if (action !== 'captainFaqSuggestions/setRecords') return;
|
||||
|
||||
mocks.getterValues['captainFaqSuggestions/getRecords'].value =
|
||||
payload.records;
|
||||
mocks.getterValues['captainFaqSuggestions/getMeta'].value = {
|
||||
totalCount: payload.meta.total_count,
|
||||
page: payload.meta.page,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the latest assistant results when an older request is superseded', async () => {
|
||||
const firstRequest = deferred();
|
||||
const secondRequest = deferred();
|
||||
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: {
|
||||
mocks: { $t: key => key },
|
||||
stubs: {
|
||||
PageLayout: PageLayoutStub,
|
||||
FaqSuggestionCard: FaqSuggestionCardStub,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
// Switching assistants aborts the first request before it resolves.
|
||||
mocks.route.params.assistantId = 2;
|
||||
await flushPromises();
|
||||
|
||||
secondRequest.resolve({
|
||||
data: {
|
||||
payload: [{ id: 2, question: 'Current assistant' }],
|
||||
meta: { page: 1, total_count: 1 },
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).toContain('Current assistant');
|
||||
expect(wrapper.text()).not.toContain('Previous assistant');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
<script setup>
|
||||
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 { useAbortableRequest } from 'dashboard/composables/useAbortableRequest';
|
||||
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';
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import FaqSuggestionCard from 'dashboard/components-next/captain/assistant/FaqSuggestionCard.vue';
|
||||
import FaqSuggestionReviewDialog from 'dashboard/components-next/captain/pageComponents/response/FaqSuggestionReviewDialog.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const suggestions = useMapGetter('captainFaqSuggestions/getRecords');
|
||||
const suggestionMeta = useMapGetter('captainFaqSuggestions/getMeta');
|
||||
const uiFlags = useMapGetter('captainFaqSuggestions/getUIFlags');
|
||||
|
||||
const searchQuery = ref('');
|
||||
const selectedSuggestion = ref(null);
|
||||
const reviewDialog = ref(null);
|
||||
const activeSuggestionId = ref(null);
|
||||
|
||||
const selectedAssistantId = computed(() => Number(route.params.assistantId));
|
||||
const isFetching = computed(() => uiFlags.value.fetchingList);
|
||||
const isMutating = computed(
|
||||
() => uiFlags.value.updatingItem || uiFlags.value.deletingItem
|
||||
);
|
||||
const hasActiveFilters = computed(() => Boolean(searchQuery.value));
|
||||
|
||||
const backUrl = computed(() => ({
|
||||
name: 'captain_assistants_responses_index',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
assistantId: selectedAssistantId.value,
|
||||
},
|
||||
}));
|
||||
|
||||
const updateURL = (page, search) => {
|
||||
const query = { page: page || 1 };
|
||||
if (search) query.search = search;
|
||||
router.replace({ query });
|
||||
};
|
||||
|
||||
const { run: runListRequest, abort: abortListRequest } = useAbortableRequest();
|
||||
|
||||
const fetchSuggestions = async (page = 1) => {
|
||||
updateURL(page, searchQuery.value);
|
||||
store.dispatch('captainFaqSuggestions/setFetchingList', true);
|
||||
|
||||
try {
|
||||
const response = await runListRequest(signal =>
|
||||
CaptainFaqSuggestionsAPI.get({
|
||||
page,
|
||||
search: searchQuery.value,
|
||||
assistantId: selectedAssistantId.value,
|
||||
signal,
|
||||
})
|
||||
);
|
||||
|
||||
if (!response) return;
|
||||
|
||||
store.dispatch('captainFaqSuggestions/setRecords', {
|
||||
records: response.data.payload,
|
||||
meta: response.data.meta,
|
||||
});
|
||||
store.dispatch('captainFaqSuggestions/setFetchingList', false);
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t('CAPTAIN.FAQ_SUGGESTIONS.ERRORS.LOAD'));
|
||||
store.dispatch('captainFaqSuggestions/setFetchingList', false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshCurrentPage = () => {
|
||||
const currentPage = suggestionMeta.value.page || 1;
|
||||
const page =
|
||||
suggestions.value.length || currentPage === 1
|
||||
? currentPage
|
||||
: currentPage - 1;
|
||||
fetchSuggestions(page);
|
||||
};
|
||||
|
||||
const handleApprove = async suggestion => {
|
||||
activeSuggestionId.value = suggestion.id;
|
||||
try {
|
||||
await store.dispatch('captainFaqSuggestions/approve', {
|
||||
id: suggestion.id,
|
||||
question: suggestion.question,
|
||||
answer: suggestion.answer,
|
||||
});
|
||||
useAlert(t('CAPTAIN.FAQ_SUGGESTIONS.SUCCESS.APPROVED'));
|
||||
refreshCurrentPage();
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t('CAPTAIN.FAQ_SUGGESTIONS.ERRORS.APPROVE'));
|
||||
} finally {
|
||||
activeSuggestionId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismiss = async suggestion => {
|
||||
activeSuggestionId.value = suggestion.id;
|
||||
try {
|
||||
await store.dispatch('captainFaqSuggestions/dismiss', suggestion.id);
|
||||
useAlert(t('CAPTAIN.FAQ_SUGGESTIONS.SUCCESS.DISMISSED'));
|
||||
refreshCurrentPage();
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t('CAPTAIN.FAQ_SUGGESTIONS.ERRORS.DISMISS'));
|
||||
} finally {
|
||||
activeSuggestionId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleReview = suggestion => {
|
||||
selectedSuggestion.value = suggestion;
|
||||
nextTick(() => reviewDialog.value.dialogRef.open());
|
||||
};
|
||||
|
||||
const handleReviewClose = () => {
|
||||
selectedSuggestion.value = null;
|
||||
};
|
||||
|
||||
const handleResolved = () => {
|
||||
refreshCurrentPage();
|
||||
};
|
||||
|
||||
const debouncedSearch = debounce(() => fetchSuggestions(1), 500);
|
||||
|
||||
const handleSearchInput = () => {
|
||||
abortListRequest();
|
||||
debouncedSearch();
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
searchQuery.value = '';
|
||||
fetchSuggestions(1);
|
||||
};
|
||||
|
||||
const initializeFromURL = () => {
|
||||
searchQuery.value = route.query.search || '';
|
||||
fetchSuggestions(parseInt(route.query.page, 10) || 1);
|
||||
};
|
||||
|
||||
watch(
|
||||
selectedAssistantId,
|
||||
() => {
|
||||
selectedSuggestion.value = null;
|
||||
activeSuggestionId.value = null;
|
||||
store.dispatch('captainFaqSuggestions/setRecords', {
|
||||
records: [],
|
||||
meta: { page: 1, total_count: 0 },
|
||||
});
|
||||
initializeFromURL();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
store.dispatch('captainFaqSuggestions/setFetchingList', false);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageLayout
|
||||
:total-count="suggestionMeta.totalCount"
|
||||
:current-page="suggestionMeta.page"
|
||||
:header-title="$t('CAPTAIN.FAQ_SUGGESTIONS.HEADER')"
|
||||
:is-fetching="isFetching"
|
||||
:is-empty="!suggestions.length"
|
||||
:show-pagination-footer="!isFetching && !!suggestions.length"
|
||||
:show-know-more="false"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN"
|
||||
:back-url="backUrl"
|
||||
@update:current-page="fetchSuggestions"
|
||||
>
|
||||
<template #search>
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('CAPTAIN.FAQ_SUGGESTIONS.SEARCH_PLACEHOLDER')"
|
||||
class="w-64"
|
||||
size="sm"
|
||||
type="search"
|
||||
autofocus
|
||||
@input="handleSearchInput"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #subHeader>
|
||||
<div
|
||||
v-if="suggestions.length"
|
||||
class="mb-2 flex items-center gap-2 text-sm text-n-slate-11"
|
||||
>
|
||||
<span class="font-medium text-n-slate-12">
|
||||
{{
|
||||
$t('CAPTAIN.FAQ_SUGGESTIONS.QUEUE_COUNT', {
|
||||
count: suggestionMeta.totalCount,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span class="border-s border-n-weak ps-2">
|
||||
{{ $t('CAPTAIN.FAQ_SUGGESTIONS.QUEUE_HINT') }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #emptyState>
|
||||
<EmptyStateLayout
|
||||
:title="$t('CAPTAIN.FAQ_SUGGESTIONS.EMPTY_STATE.TITLE')"
|
||||
:subtitle="$t('CAPTAIN.FAQ_SUGGESTIONS.EMPTY_STATE.SUBTITLE')"
|
||||
:show-backdrop="false"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
v-if="hasActiveFilters"
|
||||
:label="$t('CAPTAIN.FAQ_SUGGESTIONS.EMPTY_STATE.CLEAR_SEARCH')"
|
||||
variant="link"
|
||||
size="sm"
|
||||
@click="clearFilters"
|
||||
/>
|
||||
</template>
|
||||
</EmptyStateLayout>
|
||||
</template>
|
||||
|
||||
<template #paywall>
|
||||
<CaptainPaywall />
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-4 pb-6">
|
||||
<FaqSuggestionCard
|
||||
v-for="suggestion in suggestions"
|
||||
:key="suggestion.id"
|
||||
:suggestion="suggestion"
|
||||
:is-loading="isMutating && activeSuggestionId === suggestion.id"
|
||||
@approve="handleApprove"
|
||||
@dismiss="handleDismiss"
|
||||
@review="handleReview"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<FaqSuggestionReviewDialog
|
||||
v-if="selectedSuggestion"
|
||||
ref="reviewDialog"
|
||||
:suggestion="selectedSuggestion"
|
||||
@close="handleReviewClose"
|
||||
@resolved="handleResolved"
|
||||
/>
|
||||
</PageLayout>
|
||||
</template>
|
||||
@@ -1,11 +1,14 @@
|
||||
<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 { useAbortableRequest } from 'dashboard/composables/useAbortableRequest';
|
||||
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';
|
||||
@@ -41,7 +44,7 @@ const createDialog = ref(null);
|
||||
|
||||
const selectedAssistantId = computed(() => Number(route.params.assistantId));
|
||||
|
||||
const pendingCount = useMapGetter('captainResponses/getPendingCount');
|
||||
const suggestionCount = useMapGetter('captainFaqSuggestions/getOpenCount');
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteDialog.value.dialogRef.open();
|
||||
@@ -95,8 +98,10 @@ const updateURLWithFilters = (page, search) => {
|
||||
router.replace({ query });
|
||||
};
|
||||
|
||||
const fetchResponses = (page = 1) => {
|
||||
const filterParams = { page, status: 'approved' };
|
||||
const { run: runListRequest, abort: abortListRequest } = useAbortableRequest();
|
||||
|
||||
const fetchResponses = async (page = 1) => {
|
||||
const filterParams = { page };
|
||||
|
||||
if (selectedAssistantId.value) {
|
||||
filterParams.assistantId = selectedAssistantId.value;
|
||||
@@ -108,7 +113,24 @@ const fetchResponses = (page = 1) => {
|
||||
// Update URL with current filters
|
||||
updateURLWithFilters(page, searchQuery.value);
|
||||
|
||||
store.dispatch('captainResponses/get', filterParams);
|
||||
store.dispatch('captainResponses/setFetchingList', true);
|
||||
|
||||
try {
|
||||
const response = await runListRequest(signal =>
|
||||
CaptainResponseAPI.get({ ...filterParams, signal })
|
||||
);
|
||||
|
||||
if (!response) return;
|
||||
|
||||
store.dispatch('captainResponses/setRecords', {
|
||||
records: response.data.payload,
|
||||
meta: response.data.meta,
|
||||
});
|
||||
store.dispatch('captainResponses/setFetchingList', false);
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t('CAPTAIN.RESPONSES.ERRORS.LOAD'));
|
||||
store.dispatch('captainResponses/setFetchingList', false);
|
||||
}
|
||||
};
|
||||
|
||||
// Bulk action
|
||||
@@ -181,24 +203,47 @@ const debouncedSearch = debounce(async () => {
|
||||
fetchResponses(1);
|
||||
}, 500);
|
||||
|
||||
const handleSearchInput = () => {
|
||||
abortListRequest();
|
||||
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(
|
||||
'captainResponses/fetchPendingCount',
|
||||
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(() => {
|
||||
store.dispatch('captainResponses/setFetchingList', false);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -240,7 +285,7 @@ onMounted(() => {
|
||||
size="sm"
|
||||
type="search"
|
||||
autofocus
|
||||
@input="debouncedSearch"
|
||||
@input="handleSearchInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -271,13 +316,13 @@ onMounted(() => {
|
||||
<template #body>
|
||||
<LimitBanner class="mb-5" />
|
||||
<Banner
|
||||
v-if="pendingCount > 0"
|
||||
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">
|
||||
|
||||
@@ -1,376 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, nextTick } 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 Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
|
||||
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
|
||||
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
|
||||
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
|
||||
import CreateResponseDialog from 'dashboard/components-next/captain/pageComponents/response/CreateResponseDialog.vue';
|
||||
import ResponsePageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/ResponsePageEmptyState.vue';
|
||||
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
|
||||
import LimitBanner from 'dashboard/components-next/captain/pageComponents/response/LimitBanner.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
const uiFlags = useMapGetter('captainResponses/getUIFlags');
|
||||
const responseMeta = useMapGetter('captainResponses/getMeta');
|
||||
const responses = useMapGetter('captainResponses/getRecords');
|
||||
const isFetching = computed(() => uiFlags.value.fetchingList);
|
||||
|
||||
const selectedResponse = ref(null);
|
||||
const deleteDialog = ref(null);
|
||||
const bulkDeleteDialog = ref(null);
|
||||
|
||||
const selectedAssistantId = computed(() => route.params.assistantId);
|
||||
const dialogType = ref('');
|
||||
const searchQuery = ref('');
|
||||
const { t } = useI18n();
|
||||
|
||||
const createDialog = ref(null);
|
||||
|
||||
const backUrl = computed(() => ({
|
||||
name: 'captain_assistants_responses_index',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
assistantId: selectedAssistantId.value,
|
||||
},
|
||||
}));
|
||||
|
||||
// Filter out approved responses in pending view
|
||||
const filteredResponses = computed(() =>
|
||||
responses.value.filter(response => response.status !== 'approved')
|
||||
);
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteDialog.value.dialogRef.open();
|
||||
};
|
||||
|
||||
const handleAccept = async () => {
|
||||
try {
|
||||
await store.dispatch('captainResponses/update', {
|
||||
id: selectedResponse.value.id,
|
||||
status: 'approved',
|
||||
});
|
||||
useAlert(t(`CAPTAIN.RESPONSES.EDIT.APPROVE_SUCCESS_MESSAGE`));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.message || t(`CAPTAIN.RESPONSES.EDIT.ERROR_MESSAGE`);
|
||||
useAlert(errorMessage);
|
||||
} finally {
|
||||
selectedResponse.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
dialogType.value = 'edit';
|
||||
nextTick(() => createDialog.value.dialogRef.open());
|
||||
};
|
||||
|
||||
const handleAction = ({ action, id }) => {
|
||||
selectedResponse.value = filteredResponses.value.find(
|
||||
response => id === response.id
|
||||
);
|
||||
nextTick(() => {
|
||||
if (action === 'delete') {
|
||||
handleDelete();
|
||||
}
|
||||
if (action === 'edit') {
|
||||
handleEdit();
|
||||
}
|
||||
if (action === 'approve') {
|
||||
handleAccept();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleNavigationAction = ({ id, type }) => {
|
||||
if (type === 'Conversation') {
|
||||
router.push({
|
||||
name: 'inbox_conversation',
|
||||
params: { conversation_id: id },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateClose = () => {
|
||||
dialogType.value = '';
|
||||
selectedResponse.value = null;
|
||||
};
|
||||
|
||||
const updateURLWithFilters = (page, search) => {
|
||||
const query = {
|
||||
page: page || 1,
|
||||
};
|
||||
|
||||
if (search) {
|
||||
query.search = search;
|
||||
}
|
||||
|
||||
router.replace({ query });
|
||||
};
|
||||
|
||||
const fetchResponses = (page = 1) => {
|
||||
const filterParams = { page, status: 'pending' };
|
||||
|
||||
if (selectedAssistantId.value) {
|
||||
filterParams.assistantId = selectedAssistantId.value;
|
||||
}
|
||||
if (searchQuery.value) {
|
||||
filterParams.search = searchQuery.value;
|
||||
}
|
||||
|
||||
// Update URL with current filters
|
||||
updateURLWithFilters(page, searchQuery.value);
|
||||
|
||||
store.dispatch('captainResponses/get', filterParams);
|
||||
};
|
||||
|
||||
// Bulk action
|
||||
const bulkSelectedIds = ref(new Set());
|
||||
const hoveredCard = ref(null);
|
||||
|
||||
const buildSelectedCountLabel = computed(() => {
|
||||
const count = filteredResponses.value?.length || 0;
|
||||
const isAllSelected = bulkSelectedIds.value.size === count && count > 0;
|
||||
return isAllSelected
|
||||
? t('CAPTAIN.RESPONSES.UNSELECT_ALL', { count })
|
||||
: t('CAPTAIN.RESPONSES.SELECT_ALL', { count });
|
||||
});
|
||||
|
||||
const selectedCountLabel = computed(() => {
|
||||
return t('CAPTAIN.RESPONSES.SELECTED', {
|
||||
count: bulkSelectedIds.value.size,
|
||||
});
|
||||
});
|
||||
|
||||
const handleCardHover = (isHovered, id) => {
|
||||
hoveredCard.value = isHovered ? id : null;
|
||||
};
|
||||
|
||||
const handleCardSelect = id => {
|
||||
const selected = new Set(bulkSelectedIds.value);
|
||||
selected[selected.has(id) ? 'delete' : 'add'](id);
|
||||
bulkSelectedIds.value = selected;
|
||||
};
|
||||
|
||||
const fetchResponseAfterBulkAction = () => {
|
||||
const hasNoResponsesLeft = filteredResponses.value?.length === 0;
|
||||
const currentPage = responseMeta.value?.page;
|
||||
|
||||
if (hasNoResponsesLeft) {
|
||||
const pageToFetch = currentPage > 1 ? currentPage - 1 : currentPage;
|
||||
fetchResponses(pageToFetch);
|
||||
} else {
|
||||
fetchResponses(currentPage);
|
||||
}
|
||||
|
||||
bulkSelectedIds.value = new Set();
|
||||
};
|
||||
|
||||
const handleBulkApprove = async () => {
|
||||
try {
|
||||
await store.dispatch(
|
||||
'captainBulkActions/handleBulkApprove',
|
||||
Array.from(bulkSelectedIds.value)
|
||||
);
|
||||
|
||||
fetchResponseAfterBulkAction();
|
||||
useAlert(t('CAPTAIN.RESPONSES.BULK_APPROVE.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.message || t('CAPTAIN.RESPONSES.BULK_APPROVE.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onPageChange = page => {
|
||||
const hadSelection = bulkSelectedIds.value.size > 0;
|
||||
|
||||
fetchResponses(page);
|
||||
|
||||
if (hadSelection) {
|
||||
bulkSelectedIds.value = new Set();
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteSuccess = () => {
|
||||
if (filteredResponses.value?.length === 0 && responseMeta.value?.page > 1) {
|
||||
onPageChange(responseMeta.value.page - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const onBulkDeleteSuccess = () => {
|
||||
fetchResponseAfterBulkAction();
|
||||
};
|
||||
|
||||
const debouncedSearch = debounce(async () => {
|
||||
fetchResponses(1);
|
||||
}, 500);
|
||||
|
||||
const hasActiveFilters = computed(() => {
|
||||
return Boolean(searchQuery.value);
|
||||
});
|
||||
|
||||
const clearFilters = () => {
|
||||
searchQuery.value = '';
|
||||
fetchResponses(1);
|
||||
};
|
||||
|
||||
const initializeFromURL = () => {
|
||||
if (route.query.search) {
|
||||
searchQuery.value = route.query.search;
|
||||
}
|
||||
const pageFromURL = parseInt(route.query.page, 10) || 1;
|
||||
fetchResponses(pageFromURL);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeFromURL();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageLayout
|
||||
:total-count="responseMeta.totalCount"
|
||||
:current-page="responseMeta.page"
|
||||
:header-title="$t('CAPTAIN.RESPONSES.PENDING_FAQS')"
|
||||
:is-fetching="isFetching"
|
||||
:is-empty="!filteredResponses.length"
|
||||
:show-pagination-footer="!isFetching && !!filteredResponses.length"
|
||||
:show-know-more="false"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN"
|
||||
:back-url="backUrl"
|
||||
@update:current-page="onPageChange"
|
||||
>
|
||||
<template #knowMore>
|
||||
<FeatureSpotlightPopover
|
||||
:button-label="$t('CAPTAIN.HEADER_KNOW_MORE')"
|
||||
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
|
||||
:note="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
|
||||
:hide-actions="!isOnChatwootCloud"
|
||||
fallback-thumbnail="/assets/images/dashboard/captain/faqs-popover-light.svg"
|
||||
fallback-thumbnail-dark="/assets/images/dashboard/captain/faqs-popover-dark.svg"
|
||||
learn-more-url="https://chwt.app/captain-faq"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #search>
|
||||
<div
|
||||
v-if="bulkSelectedIds.size === 0"
|
||||
class="flex gap-3 justify-between w-full items-center"
|
||||
>
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('CAPTAIN.RESPONSES.SEARCH_PLACEHOLDER')"
|
||||
class="w-64"
|
||||
size="sm"
|
||||
type="search"
|
||||
autofocus
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #subHeader>
|
||||
<BulkSelectBar
|
||||
v-model="bulkSelectedIds"
|
||||
:all-items="filteredResponses"
|
||||
:select-all-label="buildSelectedCountLabel"
|
||||
:selected-count-label="selectedCountLabel"
|
||||
:delete-label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')"
|
||||
class="w-fit"
|
||||
:class="{
|
||||
'mb-2': bulkSelectedIds.size > 0,
|
||||
}"
|
||||
@bulk-delete="bulkDeleteDialog.dialogRef.open()"
|
||||
>
|
||||
<template #secondaryActions>
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.BULK_APPROVE_BUTTON')"
|
||||
sm
|
||||
ghost
|
||||
icon="i-lucide-check"
|
||||
class="!px-1.5"
|
||||
@click="handleBulkApprove"
|
||||
/>
|
||||
</template>
|
||||
</BulkSelectBar>
|
||||
</template>
|
||||
|
||||
<template #emptyState>
|
||||
<ResponsePageEmptyState
|
||||
variant="pending"
|
||||
:has-active-filters="hasActiveFilters"
|
||||
@clear-filters="clearFilters"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #paywall>
|
||||
<CaptainPaywall />
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<LimitBanner class="mb-5" />
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<ResponseCard
|
||||
v-for="response in filteredResponses"
|
||||
:id="response.id"
|
||||
:key="response.id"
|
||||
:question="response.question"
|
||||
:answer="response.answer"
|
||||
:assistant="response.assistant"
|
||||
:documentable="response.documentable"
|
||||
:status="response.status"
|
||||
:created-at="response.created_at"
|
||||
:updated-at="response.updated_at"
|
||||
:is-selected="bulkSelectedIds.has(response.id)"
|
||||
:selectable="hoveredCard === response.id || bulkSelectedIds.size > 0"
|
||||
:show-menu="false"
|
||||
:show-actions="!bulkSelectedIds.has(response.id)"
|
||||
@action="handleAction"
|
||||
@navigate="handleNavigationAction"
|
||||
@select="handleCardSelect"
|
||||
@hover="isHovered => handleCardHover(isHovered, response.id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<DeleteDialog
|
||||
v-if="selectedResponse"
|
||||
ref="deleteDialog"
|
||||
:entity="selectedResponse"
|
||||
type="Responses"
|
||||
@delete-success="onDeleteSuccess"
|
||||
/>
|
||||
|
||||
<BulkDeleteDialog
|
||||
v-if="bulkSelectedIds"
|
||||
ref="bulkDeleteDialog"
|
||||
:bulk-ids="bulkSelectedIds"
|
||||
type="AssistantResponse"
|
||||
@delete-success="onBulkDeleteSuccess"
|
||||
/>
|
||||
|
||||
<CreateResponseDialog
|
||||
v-if="dialogType"
|
||||
ref="createDialog"
|
||||
:type="dialogType"
|
||||
:selected-response="selectedResponse"
|
||||
@close="handleCreateClose"
|
||||
/>
|
||||
</PageLayout>
|
||||
</template>
|
||||
@@ -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',
|
||||
|
||||
76
app/javascript/dashboard/store/captain/faqSuggestions.js
Normal file
76
app/javascript/dashboard/store/captain/faqSuggestions.js
Normal file
@@ -0,0 +1,76 @@
|
||||
import CaptainFaqSuggestionsAPI from 'dashboard/api/captain/faqSuggestions';
|
||||
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',
|
||||
API: CaptainFaqSuggestionsAPI,
|
||||
getters: {
|
||||
getRecords: state => state.records,
|
||||
getOpenCount: state => state.meta.openCount || 0,
|
||||
},
|
||||
mutations: {
|
||||
[SET_OPEN_COUNT](state, count) {
|
||||
state.meta = {
|
||||
...state.meta,
|
||||
openCount: 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);
|
||||
},
|
||||
approve: async ({ commit }, { id, question, answer }) => {
|
||||
commit(mutations.SET_UI_FLAG, { updatingItem: true });
|
||||
try {
|
||||
const response = await CaptainFaqSuggestionsAPI.approve(id, {
|
||||
question,
|
||||
answer,
|
||||
});
|
||||
commit(mutations.DELETE, id);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(mutations.SET_UI_FLAG, { updatingItem: false });
|
||||
}
|
||||
},
|
||||
dismiss: async ({ commit }, id) => {
|
||||
commit(mutations.SET_UI_FLAG, { deletingItem: true });
|
||||
try {
|
||||
await CaptainFaqSuggestionsAPI.dismiss(id);
|
||||
commit(mutations.DELETE, id);
|
||||
return id;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(mutations.SET_UI_FLAG, { deletingItem: false });
|
||||
}
|
||||
},
|
||||
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 {
|
||||
if (requestId === openCountRequestId) commit(SET_OPEN_COUNT, 0);
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -54,6 +54,7 @@ import captainAgentSessions from './captain/agentSessions';
|
||||
import captainAssistants from './captain/assistant';
|
||||
import captainDocuments from './captain/document';
|
||||
import captainResponses from './captain/response';
|
||||
import captainFaqSuggestions from './captain/faqSuggestions';
|
||||
import captainInboxes from './captain/inboxes';
|
||||
import captainBulkActions from './captain/bulkActions';
|
||||
import copilotThreads from './captain/copilotThreads';
|
||||
@@ -120,6 +121,7 @@ export default createStore({
|
||||
captainAssistants,
|
||||
captainDocuments,
|
||||
captainResponses,
|
||||
captainFaqSuggestions,
|
||||
captainInboxes,
|
||||
captainBulkActions,
|
||||
copilotThreads,
|
||||
|
||||
Reference in New Issue
Block a user