feat(captain): show document conversation usage (#15140)
## Summary Adds conversation usage to Captain documents and user created FAQs. Only knowledge used in a Captain answer sent to the contact is counted. Lookups that end in a handoff are excluded. Administrators can see how many distinct conversations used a knowledge record. Document usage appears in the Usage tab inside document details. User created FAQ usage appears on each FAQ card and opens in a conversation panel. Deleted conversations are excluded from counts, sorting, and conversation lists. Usage shown in side panel <img width="2342" height="1502" alt="CleanShot 2026-08-12 at 18 29 02@2x" src="https://github.com/user-attachments/assets/67301051-6df5-4eb6-9b8d-e1fc75bb02f2" /> Sorting options <img width="580" height="241" alt="image" src="https://github.com/user-attachments/assets/078566b7-481b-491b-b484-c2fef481f1b1" /> ## Access Conversation usage is available only to administrators. Agents can still view documents and FAQs, but they cannot see usage counts, the Usage tab, the "Most used" sort, or conversation usage details. The API applies the same rule. An agent request for `sort=most_used` is rejected. ## Performance and pagination Document and FAQ lists return 25 records per page. Usage counts are calculated only for the records in each page. Conversation usage panels load 25 conversations at a time and show a "Load more" action when more conversations are available. The generated FAQs tab also keeps its existing 25 item pagination. The count queries use the JSON indexes on `document_ids` and `used_faq_ids`. The "Most used" sort aggregates one assistant's sessions once before it sorts and returns the requested document page. ## How to test 1. Sign in as an administrator and open the Captain documents page for an assistant with tracked document usage. 2. Open a document and confirm that the Usage tab shows the distinct conversation count and the matching conversations. 3. Select "Most used" and confirm that documents are ordered by distinct conversation usage. 4. Open the user created FAQs page and confirm that FAQ cards show their usage count and open the matching conversations. 5. Confirm that usage panels show 25 conversations first and can load the next page. 6. Trigger a knowledge lookup that ends in a handoff and confirm that it does not increase document or FAQ usage. 7. Sign in as an agent and confirm that usage counts, usage details, and the "Most used" sort are not available. ## Closes [CW-7498](https://linear.app/chatwoot/issue/CW-7498/fe)
This commit is contained in:
@@ -22,6 +22,13 @@ class CaptainDocument extends ApiClient {
|
||||
sync(id) {
|
||||
return axios.post(`${this.url}/${id}/sync`);
|
||||
}
|
||||
|
||||
getDrilldown({ documentId, page, signal }) {
|
||||
const requestConfig = { params: { page } };
|
||||
if (signal) requestConfig.signal = signal;
|
||||
|
||||
return axios.get(`${this.url}/${documentId}/drilldown`, requestConfig);
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainDocument();
|
||||
|
||||
@@ -17,6 +17,13 @@ class CaptainResponses extends ApiClient {
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
getDrilldown({ responseId, page, signal }) {
|
||||
const requestConfig = { params: { page } };
|
||||
if (signal) requestConfig.signal = signal;
|
||||
|
||||
return axios.get(`${this.url}/${responseId}/drilldown`, requestConfig);
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainResponses();
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import DocumentCard from './DocumentCard.vue';
|
||||
|
||||
const { checkPermissions } = vi.hoisted(() => ({
|
||||
checkPermissions: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables/usePolicy', () => ({
|
||||
usePolicy: () => ({ checkPermissions }),
|
||||
}));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key, { n } = {}) => {
|
||||
if (key === 'CAPTAIN.DOCUMENTS.FAQ_COUNT') return `${n} FAQs`;
|
||||
if (key === 'CAPTAIN.DOCUMENTS.USED_IN_CONVERSATIONS') {
|
||||
return `Used in ${n} conversations`;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const ButtonStub = {
|
||||
inheritAttrs: false,
|
||||
props: ['label', 'disabled'],
|
||||
emits: ['click'],
|
||||
template:
|
||||
'<button v-bind="$attrs" :disabled="disabled" @click="$emit(\'click\', $event)">{{ label }}</button>',
|
||||
};
|
||||
|
||||
const mountCard = (props = {}) =>
|
||||
shallowMount(DocumentCard, {
|
||||
props: {
|
||||
id: 42,
|
||||
name: 'Returns and refunds',
|
||||
assistant: { name: 'Acme assistant' },
|
||||
externalLink:
|
||||
'https://example.com/help/articles/refund-and-return-policy-for-online-orders',
|
||||
createdAt: 1_700_000_000,
|
||||
status: 'available',
|
||||
responsesCount: 12,
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
directives: { onClickaway: {} },
|
||||
stubs: {
|
||||
Button: ButtonStub,
|
||||
CardLayout: { template: '<div><slot /></div>' },
|
||||
DocumentSyncStatus: true,
|
||||
DropdownMenu: true,
|
||||
Checkbox: true,
|
||||
Icon: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('DocumentCard', () => {
|
||||
beforeEach(() => {
|
||||
checkPermissions.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('keeps the source URL flexible and the metadata row on one line', () => {
|
||||
const wrapper = mountCard();
|
||||
const sourceLink = wrapper.get('a[href^="https://example.com"]');
|
||||
const metadataRow = sourceLink.element.parentElement;
|
||||
|
||||
expect(sourceLink.classes()).toContain('flex-1');
|
||||
expect(sourceLink.classes()).toContain('truncate');
|
||||
expect(metadataRow.classList).not.toContain('flex-wrap');
|
||||
});
|
||||
|
||||
it('keeps conversation usage out of the document card', () => {
|
||||
const wrapper = mountCard();
|
||||
|
||||
expect(wrapper.find('[aria-label^="Used in"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('opens details from the document title without a separate action', async () => {
|
||||
const wrapper = mountCard();
|
||||
const titleButton = wrapper
|
||||
.findAll('button')
|
||||
.find(button => button.text() === 'Returns and refunds');
|
||||
|
||||
expect(titleButton).toBeDefined();
|
||||
expect(wrapper.text()).not.toContain('View details');
|
||||
await titleButton.trigger('click');
|
||||
|
||||
expect(wrapper.emitted('action')).toEqual([
|
||||
[{ action: 'viewDetails', id: 42 }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -114,14 +114,7 @@ const isRetryableSync = computed(
|
||||
const showSyncStatus = computed(() => !isPdf.value);
|
||||
|
||||
const menuItems = computed(() => {
|
||||
const allOptions = [
|
||||
{
|
||||
label: t('CAPTAIN.DOCUMENTS.OPTIONS.VIEW_DETAILS'),
|
||||
value: 'viewDetails',
|
||||
action: 'viewDetails',
|
||||
icon: 'i-lucide-eye',
|
||||
},
|
||||
];
|
||||
const allOptions = [];
|
||||
|
||||
if (canSync.value) {
|
||||
allOptions.push({
|
||||
@@ -150,7 +143,6 @@ const createdAtLabel = computed(() => dynamicTime(props.createdAt));
|
||||
const responsesCountLabel = computed(() =>
|
||||
t('CAPTAIN.DOCUMENTS.FAQ_COUNT', { n: props.responsesCount })
|
||||
);
|
||||
|
||||
const displayLink = computed(() =>
|
||||
isPdf.value
|
||||
? formatDocumentLink(props.externalLink)
|
||||
@@ -195,25 +187,24 @@ const handleRetry = () => {
|
||||
>
|
||||
{{ name }}
|
||||
</button>
|
||||
<div v-if="showMenu" class="flex gap-2 items-center">
|
||||
<div
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
class="flex relative items-center group"
|
||||
>
|
||||
<Button
|
||||
icon="i-lucide-ellipsis-vertical"
|
||||
color="slate"
|
||||
size="xs"
|
||||
class="rounded-md group-hover:bg-n-alpha-2"
|
||||
@click="toggleDropdown()"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="showActionsDropdown"
|
||||
:menu-items="menuItems"
|
||||
class="top-full mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0"
|
||||
@action="handleAction($event)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="showMenu && menuItems.length"
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
class="flex relative items-center group"
|
||||
>
|
||||
<Button
|
||||
icon="i-lucide-ellipsis-vertical"
|
||||
color="slate"
|
||||
size="xs"
|
||||
class="rounded-md group-hover:bg-n-alpha-2"
|
||||
@click="toggleDropdown()"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="showActionsDropdown"
|
||||
:menu-items="menuItems"
|
||||
class="top-full mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0"
|
||||
@action="handleAction($event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-4 justify-between items-center w-full">
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import DocumentFiltersBar from './DocumentFiltersBar.vue';
|
||||
|
||||
const { checkPermissions } = vi.hoisted(() => ({
|
||||
checkPermissions: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables/usePolicy', () => ({
|
||||
usePolicy: () => ({ checkPermissions }),
|
||||
}));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: key => key.split('.').at(-1).replaceAll('_', ' '),
|
||||
}),
|
||||
}));
|
||||
|
||||
const ButtonStub = {
|
||||
props: ['icon'],
|
||||
emits: ['click'],
|
||||
template: '<button @click="$emit(\'click\')"><slot /></button>',
|
||||
};
|
||||
|
||||
const DropdownMenuStub = {
|
||||
props: ['menuItems'],
|
||||
emits: ['action'],
|
||||
template: '<div data-test="dropdown-menu" />',
|
||||
};
|
||||
|
||||
const mountFilterBar = () =>
|
||||
mount(DocumentFiltersBar, {
|
||||
global: {
|
||||
stubs: {
|
||||
Button: ButtonStub,
|
||||
DropdownMenu: DropdownMenuStub,
|
||||
Icon: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('DocumentFiltersBar', () => {
|
||||
beforeEach(() => {
|
||||
checkPermissions.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('sorts documents by conversation usage from the existing sort menu', async () => {
|
||||
const wrapper = mountFilterBar();
|
||||
const buttons = wrapper.findAllComponents(ButtonStub);
|
||||
|
||||
await buttons.at(-1).trigger('click');
|
||||
|
||||
const dropdown = wrapper.getComponent(DropdownMenuStub);
|
||||
expect(dropdown.props('menuItems')).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
label: 'MOST USED',
|
||||
value: 'most_used',
|
||||
icon: 'i-lucide-messages-square',
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
dropdown.vm.$emit('action', { action: 'sort', value: 'most_used' });
|
||||
expect(wrapper.emitted('selectSort')).toEqual([['most_used']]);
|
||||
});
|
||||
|
||||
it('hides conversation usage sorting from agents', async () => {
|
||||
checkPermissions.mockReturnValue(false);
|
||||
const wrapper = mountFilterBar();
|
||||
const buttons = wrapper.findAllComponents(ButtonStub);
|
||||
|
||||
await buttons.at(-1).trigger('click');
|
||||
|
||||
const dropdown = wrapper.getComponent(DropdownMenuStub);
|
||||
expect(dropdown.props('menuItems')).not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ value: 'most_used' })])
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
@@ -16,8 +17,10 @@ const props = defineProps({
|
||||
const emit = defineEmits(['selectSource', 'selectStatus', 'selectSort']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const { checkPermissions } = usePolicy();
|
||||
|
||||
const openMenu = ref(null);
|
||||
const canViewUsage = computed(() => checkPermissions(['administrator']));
|
||||
|
||||
const MENU_CONFIG = [
|
||||
{
|
||||
@@ -73,6 +76,11 @@ const MENU_CONFIG = [
|
||||
value: 'recently_created',
|
||||
icon: 'i-lucide-clock',
|
||||
},
|
||||
{
|
||||
labelKey: 'SORT.MOST_USED',
|
||||
value: 'most_used',
|
||||
icon: 'i-lucide-messages-square',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -82,13 +90,15 @@ const filterMenus = computed(() =>
|
||||
menu => !(menu.key === 'status' && props.activeSourceFilter === 'pdf')
|
||||
).map(menu => {
|
||||
const active = props[menu.activeKey];
|
||||
const items = menu.options.map(opt => ({
|
||||
label: t(`CAPTAIN.DOCUMENTS.FILTERS.${opt.labelKey}`),
|
||||
value: opt.value,
|
||||
icon: opt.icon,
|
||||
action: menu.key,
|
||||
isSelected: opt.value === active,
|
||||
}));
|
||||
const items = menu.options
|
||||
.filter(option => option.value !== 'most_used' || canViewUsage.value)
|
||||
.map(opt => ({
|
||||
label: t(`CAPTAIN.DOCUMENTS.FILTERS.${opt.labelKey}`),
|
||||
value: opt.value,
|
||||
icon: opt.icon,
|
||||
action: menu.key,
|
||||
isSelected: opt.value === active,
|
||||
}));
|
||||
return {
|
||||
...menu,
|
||||
items,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import ResponseCard from './ResponseCard.vue';
|
||||
|
||||
const { checkPermissions } = vi.hoisted(() => ({
|
||||
checkPermissions: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables/usePolicy', () => ({
|
||||
usePolicy: () => ({ checkPermissions }),
|
||||
}));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key, { n } = {}) => {
|
||||
if (key === 'CAPTAIN.DOCUMENTS.USED_IN_CONVERSATIONS') {
|
||||
return `Used in ${n} conversations`;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const ButtonStub = {
|
||||
inheritAttrs: false,
|
||||
props: ['label', 'disabled'],
|
||||
emits: ['click'],
|
||||
template:
|
||||
'<button v-bind="$attrs" :disabled="disabled" @click="$emit(\'click\', $event)">{{ label }}</button>',
|
||||
};
|
||||
|
||||
const mountCard = (props = {}) =>
|
||||
shallowMount(ResponseCard, {
|
||||
props: {
|
||||
id: 42,
|
||||
question: 'How long do refunds take?',
|
||||
answer: 'Refunds take five business days.',
|
||||
assistant: { name: 'Acme assistant' },
|
||||
documentable: {
|
||||
id: 1,
|
||||
type: 'User',
|
||||
available_name: 'John',
|
||||
},
|
||||
createdAt: 1_700_000_000,
|
||||
updatedAt: 1_700_000_000,
|
||||
usedInConversationsCount: 4,
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
directives: { onClickaway: {} },
|
||||
stubs: {
|
||||
Button: ButtonStub,
|
||||
CardLayout: { template: '<div><slot /></div>' },
|
||||
Policy: { template: '<div><slot /></div>' },
|
||||
DropdownMenu: true,
|
||||
Checkbox: true,
|
||||
Icon: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('ResponseCard', () => {
|
||||
beforeEach(() => {
|
||||
checkPermissions.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('opens conversation usage for a user-created FAQ', async () => {
|
||||
const wrapper = mountCard();
|
||||
const usageButton = wrapper.get('[aria-label="Used in 4 conversations"]');
|
||||
|
||||
expect(usageButton.text()).toBe('4');
|
||||
await usageButton.trigger('click');
|
||||
expect(wrapper.emitted('viewConversations')).toEqual([[42]]);
|
||||
});
|
||||
|
||||
it('shows zero usage as disabled metadata', () => {
|
||||
const wrapper = mountCard({ usedInConversationsCount: 0 });
|
||||
const usageButton = wrapper.get('[aria-label="Used in 0 conversations"]');
|
||||
|
||||
expect(usageButton.text()).toBe('0');
|
||||
expect(usageButton.attributes('disabled')).toBeDefined();
|
||||
});
|
||||
|
||||
it('does not show usage for an FAQ without a user source', () => {
|
||||
const wrapper = mountCard({
|
||||
documentable: null,
|
||||
usedInConversationsCount: 4,
|
||||
});
|
||||
|
||||
expect(wrapper.find('[aria-label^="Used in"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('hides usage analytics from users who cannot manage the assistant', () => {
|
||||
checkPermissions.mockReturnValue(false);
|
||||
const wrapper = mountCard();
|
||||
|
||||
expect(wrapper.find('[aria-label^="Used in"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { computed } from 'vue';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
@@ -48,6 +49,10 @@ const props = defineProps({
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
usedInConversationsCount: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
isSelected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -66,9 +71,16 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action', 'navigate', 'select', 'hover']);
|
||||
const emit = defineEmits([
|
||||
'action',
|
||||
'navigate',
|
||||
'select',
|
||||
'hover',
|
||||
'viewConversations',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
const { checkPermissions } = usePolicy();
|
||||
|
||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||
|
||||
@@ -110,6 +122,20 @@ const menuItems = computed(() => [
|
||||
const timestamp = computed(() =>
|
||||
dynamicTime(props.updatedAt || props.createdAt)
|
||||
);
|
||||
const canManage = computed(() => checkPermissions(['administrator']));
|
||||
const hasConversationUsage = computed(
|
||||
() =>
|
||||
props.documentable?.type === 'User' &&
|
||||
props.usedInConversationsCount !== null
|
||||
);
|
||||
const usedInConversationsLabel = computed(() =>
|
||||
t('CAPTAIN.DOCUMENTS.USED_IN_CONVERSATIONS', {
|
||||
n: props.usedInConversationsCount,
|
||||
})
|
||||
);
|
||||
const usedInConversationsCountText = computed(() =>
|
||||
String(props.usedInConversationsCount)
|
||||
);
|
||||
|
||||
const handleAssistantAction = ({ action, value }) => {
|
||||
toggleDropdown(false);
|
||||
@@ -122,6 +148,12 @@ const handleDocumentableClick = () => {
|
||||
type: props.documentable.type,
|
||||
});
|
||||
};
|
||||
|
||||
const handleViewConversations = () => {
|
||||
if (!props.usedInConversationsCount) return;
|
||||
|
||||
emit('viewConversations', props.id);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -267,11 +299,25 @@ const handleDocumentableClick = () => {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1"
|
||||
>
|
||||
<Icon icon="i-ph-calendar-dot" class="size-3.5" />
|
||||
{{ timestamp }}
|
||||
<div class="inline-flex shrink-0 items-center gap-3">
|
||||
<Button
|
||||
v-if="canManage && hasConversationUsage"
|
||||
v-tooltip.top="usedInConversationsLabel"
|
||||
:label="usedInConversationsCountText"
|
||||
:aria-label="usedInConversationsLabel"
|
||||
:disabled="!usedInConversationsCount"
|
||||
icon="i-lucide-messages-square"
|
||||
size="xs"
|
||||
slate
|
||||
link
|
||||
@click.stop="handleViewConversations"
|
||||
/>
|
||||
<div
|
||||
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1"
|
||||
>
|
||||
<Icon icon="i-ph-calendar-dot" class="size-3.5" />
|
||||
{{ timestamp }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import ConversationUsageDrawer from './ConversationUsageDrawer.vue';
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key, { n } = {}) => {
|
||||
if (key === 'CAPTAIN.DOCUMENTS.USED_IN_CONVERSATIONS') {
|
||||
return `Used in ${n} ${n === 1 ? 'conversation' : 'conversations'}`;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const payload = [
|
||||
{
|
||||
record_type: 'conversation',
|
||||
conversation: { id: 10, display_id: 42 },
|
||||
message: null,
|
||||
occurred_at: 1_700_000_000,
|
||||
},
|
||||
];
|
||||
|
||||
const SidePanelStub = {
|
||||
name: 'SidePanel',
|
||||
methods: { open() {}, close() {} },
|
||||
emits: ['close'],
|
||||
template:
|
||||
'<section><slot name="header" /><slot /><button data-test="close" @click="$emit(\'close\')" /></section>',
|
||||
};
|
||||
|
||||
const mountDrawer = async fetcher => {
|
||||
const wrapper = mount(ConversationUsageDrawer, {
|
||||
props: {
|
||||
open: false,
|
||||
resourceId: 7,
|
||||
title: 'Returns and refunds',
|
||||
conversationCount: 1,
|
||||
fetcher,
|
||||
emptyStateKey: 'CAPTAIN.RESPONSES.NO_USED_CONVERSATIONS',
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
SidePanel: SidePanelStub,
|
||||
Spinner: true,
|
||||
Button: {
|
||||
props: ['label'],
|
||||
template: '<button>{{ label }}</button>',
|
||||
},
|
||||
ReportDrilldownCard: {
|
||||
props: ['record'],
|
||||
template:
|
||||
'<div data-test="conversation-card">#{{ record.conversation.display_id }}</div>',
|
||||
},
|
||||
},
|
||||
mocks: { $t: key => key },
|
||||
},
|
||||
});
|
||||
await wrapper.setProps({ open: true });
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
describe('ConversationUsageDrawer', () => {
|
||||
it('loads conversations through the supplied shared drilldown fetcher', async () => {
|
||||
const fetcher = vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
meta: { current_page: 1, total_count: 1, conversation_count: 1 },
|
||||
payload,
|
||||
},
|
||||
});
|
||||
const wrapper = await mountDrawer(fetcher);
|
||||
await flushPromises();
|
||||
|
||||
expect(fetcher).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ resourceId: 7, page: 1 })
|
||||
);
|
||||
expect(wrapper.text()).toContain('Returns and refunds');
|
||||
expect(wrapper.text()).toContain('Used in 1 conversation');
|
||||
expect(wrapper.get('[data-test="conversation-card"]').text()).toBe('#42');
|
||||
});
|
||||
|
||||
it('emits close when the shared side panel closes', async () => {
|
||||
const fetcher = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ data: { meta: {}, payload: [] } });
|
||||
const wrapper = await mountDrawer(fetcher);
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.get('[data-test="close"]').trigger('click');
|
||||
|
||||
expect(wrapper.emitted('close')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useReportDrilldown } from 'dashboard/routes/dashboard/settings/reports/composables/useReportDrilldown';
|
||||
import ReportDrilldownCard from 'dashboard/routes/dashboard/settings/reports/components/ReportDrilldownCard.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import SidePanel from 'dashboard/components-next/side-panel/SidePanel.vue';
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
resourceId: { type: [Number, String], default: null },
|
||||
title: { type: String, default: '' },
|
||||
conversationCount: { type: Number, default: 0 },
|
||||
fetcher: { type: Function, required: true },
|
||||
emptyStateKey: { type: String, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const panelRef = ref(null);
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
records,
|
||||
meta,
|
||||
isFetching,
|
||||
isFetchingMore,
|
||||
hasError,
|
||||
hasRecords,
|
||||
hasMore,
|
||||
open: openDrilldown,
|
||||
close,
|
||||
loadMore,
|
||||
} = useReportDrilldown(params => props.fetcher(params));
|
||||
|
||||
const resolvedConversationCount = computed(
|
||||
() => meta.value.conversation_count ?? props.conversationCount
|
||||
);
|
||||
|
||||
const subtitle = computed(() =>
|
||||
t('CAPTAIN.DOCUMENTS.USED_IN_CONVERSATIONS', {
|
||||
n: resolvedConversationCount.value,
|
||||
})
|
||||
);
|
||||
|
||||
const recordKey = record =>
|
||||
`${record.record_type}-${record.message?.id || record.conversation?.id}-${
|
||||
record.occurred_at
|
||||
}`;
|
||||
|
||||
const fetchDrilldown = () => {
|
||||
if (!props.resourceId) return;
|
||||
|
||||
openDrilldown({ resourceId: props.resourceId });
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
isDrawerOpen => {
|
||||
if (!isDrawerOpen) {
|
||||
panelRef.value?.close();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
panelRef.value?.open();
|
||||
fetchDrilldown();
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.resourceId,
|
||||
() => {
|
||||
if (props.open) fetchDrilldown();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidePanel ref="panelRef" :title="title" width="xl" @close="emit('close')">
|
||||
<template #header>
|
||||
<div class="min-w-0">
|
||||
<h3 class="truncate text-base font-medium text-n-slate-12">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-n-slate-11">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="isFetching" class="flex h-40 items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="hasError"
|
||||
class="flex h-40 items-center justify-center text-sm text-n-ruby-11"
|
||||
>
|
||||
{{ $t('CAPTAIN.OVERVIEW.DRILLDOWN.ERROR') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!hasRecords"
|
||||
class="flex h-40 items-center justify-center text-sm text-n-slate-10"
|
||||
>
|
||||
{{ $t(emptyStateKey) }}
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<ReportDrilldownCard
|
||||
v-for="record in records"
|
||||
:key="recordKey(record)"
|
||||
:record="record"
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-if="hasMore"
|
||||
faded
|
||||
slate
|
||||
size="sm"
|
||||
class="mx-auto mt-2"
|
||||
:label="$t('CAPTAIN.OVERVIEW.DRILLDOWN.LOAD_MORE')"
|
||||
:is-loading="isFetchingMore"
|
||||
@click="loadMore"
|
||||
/>
|
||||
</div>
|
||||
</SidePanel>
|
||||
</template>
|
||||
@@ -1,8 +1,9 @@
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils';
|
||||
import DocumentDetails from './DocumentDetails.vue';
|
||||
|
||||
const { dispatch, getterValues } = vi.hoisted(() => ({
|
||||
const { dispatch, getDrilldown, getterValues } = vi.hoisted(() => ({
|
||||
dispatch: vi.fn(),
|
||||
getDrilldown: vi.fn(),
|
||||
getterValues: {
|
||||
'captainResponses/getUIFlags': { value: { fetchingList: false } },
|
||||
'captainResponses/getRecords': { value: [] },
|
||||
@@ -10,15 +11,32 @@ const { dispatch, getterValues } = vi.hoisted(() => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const { checkPermissions } = vi.hoisted(() => ({
|
||||
checkPermissions: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables/store', () => ({
|
||||
useStore: () => ({ dispatch }),
|
||||
useMapGetter: key => getterValues[key],
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables', () => ({ useAlert: vi.fn() }));
|
||||
vi.mock('dashboard/composables/usePolicy', () => ({
|
||||
usePolicy: () => ({ checkPermissions }),
|
||||
}));
|
||||
vi.mock('dashboard/api/captain/document', () => ({
|
||||
default: { getDrilldown },
|
||||
}));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: key => key }),
|
||||
useI18n: () => ({
|
||||
t: (key, { n } = {}) => {
|
||||
if (key === 'CAPTAIN.DOCUMENTS.USED_IN_CONVERSATIONS') {
|
||||
return `Used in ${n} conversations`;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const captainDocument = {
|
||||
@@ -28,6 +46,7 @@ const captainDocument = {
|
||||
assistant: { id: 7 },
|
||||
content: 'Document content',
|
||||
pdf_document: false,
|
||||
used_in_conversations_count: 8,
|
||||
};
|
||||
|
||||
const SidePanelStub = {
|
||||
@@ -38,8 +57,17 @@ const SidePanelStub = {
|
||||
|
||||
const TabBarStub = {
|
||||
name: 'TabBar',
|
||||
template:
|
||||
'<button data-test="faq-tab" @click="$emit(\'tabChanged\', { key: \'faqs\' })" />',
|
||||
props: ['tabs'],
|
||||
template: `
|
||||
<div>
|
||||
<button data-test="faq-tab" @click="$emit('tabChanged', { key: 'faqs' })" />
|
||||
<button
|
||||
v-if="tabs.some(tab => tab.key === 'usage')"
|
||||
data-test="usage-tab"
|
||||
@click="$emit('tabChanged', { key: 'usage' })"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
|
||||
const PaginationFooterStub = {
|
||||
@@ -48,10 +76,32 @@ const PaginationFooterStub = {
|
||||
'<button data-test="next-page" @click="$emit(\'update:currentPage\', 2)" />',
|
||||
};
|
||||
|
||||
const ButtonStub = {
|
||||
inheritAttrs: false,
|
||||
props: ['label', 'disabled'],
|
||||
emits: ['click'],
|
||||
template:
|
||||
'<button v-bind="$attrs" :disabled="disabled" @click="$emit(\'click\', $event)">{{ label }}</button>',
|
||||
};
|
||||
|
||||
describe('DocumentDetails', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
dispatch.mockResolvedValue([]);
|
||||
getDrilldown.mockResolvedValue({
|
||||
data: {
|
||||
meta: { current_page: 1, total_count: 1, conversation_count: 1 },
|
||||
payload: [
|
||||
{
|
||||
record_type: 'conversation',
|
||||
conversation: { id: 10, display_id: 77 },
|
||||
message: null,
|
||||
occurred_at: 1_700_000_000,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
checkPermissions.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('requests another FAQ page when the document has more than 25 FAQs', async () => {
|
||||
@@ -63,6 +113,12 @@ describe('DocumentDetails', () => {
|
||||
SidePanel: SidePanelStub,
|
||||
TabBar: TabBarStub,
|
||||
PaginationFooter: PaginationFooterStub,
|
||||
Button: ButtonStub,
|
||||
ReportDrilldownCard: {
|
||||
props: ['record'],
|
||||
template:
|
||||
'<div data-test="conversation-card">#{{ record.conversation.display_id }}</div>',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -84,4 +140,70 @@ describe('DocumentDetails', () => {
|
||||
documentId: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it('loads document usage in the third details tab', async () => {
|
||||
const wrapper = shallowMount(DocumentDetails, {
|
||||
props: { captainDocument },
|
||||
global: {
|
||||
directives: { dompurifyHtml: {} },
|
||||
stubs: {
|
||||
SidePanel: SidePanelStub,
|
||||
TabBar: TabBarStub,
|
||||
PaginationFooter: PaginationFooterStub,
|
||||
Button: ButtonStub,
|
||||
ReportDrilldownCard: {
|
||||
props: ['record'],
|
||||
template:
|
||||
'<div data-test="conversation-card">#{{ record.conversation.display_id }}</div>',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.getComponent(TabBarStub).props('tabs')).toEqual([
|
||||
{
|
||||
key: 'content',
|
||||
label: 'CAPTAIN.DOCUMENTS.DETAILS.CONTENT_TAB',
|
||||
},
|
||||
{
|
||||
key: 'faqs',
|
||||
label: 'CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE',
|
||||
count: 26,
|
||||
},
|
||||
{
|
||||
key: 'usage',
|
||||
label: 'CAPTAIN.DOCUMENTS.DETAILS.USED_IN_CONVERSATIONS',
|
||||
count: 8,
|
||||
},
|
||||
]);
|
||||
|
||||
await wrapper.get('[data-test="usage-tab"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(getDrilldown).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ documentId: 42, page: 1 })
|
||||
);
|
||||
expect(wrapper.get('[data-test="conversation-card"]').text()).toBe('#77');
|
||||
expect(wrapper.emitted('viewConversations')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('hides the document usage tab from users who cannot manage the assistant', () => {
|
||||
checkPermissions.mockReturnValue(false);
|
||||
|
||||
const wrapper = shallowMount(DocumentDetails, {
|
||||
props: { captainDocument },
|
||||
global: {
|
||||
directives: { dompurifyHtml: {} },
|
||||
stubs: {
|
||||
SidePanel: SidePanelStub,
|
||||
TabBar: TabBarStub,
|
||||
PaginationFooter: PaginationFooterStub,
|
||||
Button: ButtonStub,
|
||||
ReportDrilldownCard: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.find('[data-test="usage-tab"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { messageTimestamp } from 'shared/helpers/timeHelper';
|
||||
@@ -17,6 +18,9 @@ import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
||||
import CaptainDocumentAPI from 'dashboard/api/captain/document';
|
||||
import { useReportDrilldown } from 'dashboard/routes/dashboard/settings/reports/composables/useReportDrilldown';
|
||||
import ReportDrilldownCard from 'dashboard/routes/dashboard/settings/reports/components/ReportDrilldownCard.vue';
|
||||
import ResponseCard from '../../assistant/ResponseCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -29,16 +33,19 @@ const emit = defineEmits(['close']);
|
||||
const TAB_KEYS = {
|
||||
CONTENT: 'content',
|
||||
FAQS: 'faqs',
|
||||
USAGE: 'usage',
|
||||
};
|
||||
const RESPONSES_PER_PAGE = 25;
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const { checkPermissions } = usePolicy();
|
||||
// The parent mounts this component with v-if, so the panel opens on mount and
|
||||
// the parent unmounts it only after the slide-out finishes (afterLeave).
|
||||
const panelRef = ref(null);
|
||||
const documentDetails = computed(() => props.captainDocument);
|
||||
const showRawContent = ref(false);
|
||||
const activeTabIndex = ref(0);
|
||||
const canManage = computed(() => checkPermissions(['administrator']));
|
||||
|
||||
const uiFlags = useMapGetter('captainResponses/getUIFlags');
|
||||
const responses = useMapGetter('captainResponses/getRecords');
|
||||
@@ -67,14 +74,29 @@ const contentTabLabel = computed(() =>
|
||||
? t('CAPTAIN.DOCUMENTS.DETAILS.PDF_TAB')
|
||||
: t('CAPTAIN.DOCUMENTS.DETAILS.CONTENT_TAB')
|
||||
);
|
||||
const tabs = computed(() => [
|
||||
{ key: TAB_KEYS.CONTENT, label: contentTabLabel.value },
|
||||
{
|
||||
key: TAB_KEYS.FAQS,
|
||||
label: t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE'),
|
||||
count: totalCount.value,
|
||||
},
|
||||
]);
|
||||
const usedInConversationsCount = computed(
|
||||
() => documentDetails.value.used_in_conversations_count || 0
|
||||
);
|
||||
const tabs = computed(() => {
|
||||
const documentTabs = [
|
||||
{ key: TAB_KEYS.CONTENT, label: contentTabLabel.value },
|
||||
{
|
||||
key: TAB_KEYS.FAQS,
|
||||
label: t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE'),
|
||||
count: totalCount.value,
|
||||
},
|
||||
];
|
||||
|
||||
if (canManage.value) {
|
||||
documentTabs.push({
|
||||
key: TAB_KEYS.USAGE,
|
||||
label: t('CAPTAIN.DOCUMENTS.DETAILS.USED_IN_CONVERSATIONS'),
|
||||
count: usedInConversationsCount.value,
|
||||
});
|
||||
}
|
||||
|
||||
return documentTabs;
|
||||
});
|
||||
const activeTabKey = computed(() => tabs.value[activeTabIndex.value]?.key);
|
||||
const isUnreadableContent = computed(() => {
|
||||
if (!documentContent.value) return false;
|
||||
@@ -132,6 +154,24 @@ const documentTitle = computed(
|
||||
() => documentDetails.value.name || documentDetails.value.external_link
|
||||
);
|
||||
|
||||
const fetchDocumentUsage = ({ resourceId, ...params }) =>
|
||||
CaptainDocumentAPI.getDrilldown({ documentId: resourceId, ...params });
|
||||
|
||||
const {
|
||||
records: usageRecords,
|
||||
isFetching: isUsageFetching,
|
||||
isFetchingMore: isUsageFetchingMore,
|
||||
hasError: hasUsageError,
|
||||
hasRecords: hasUsageRecords,
|
||||
hasMore: hasMoreUsage,
|
||||
open: openUsage,
|
||||
close: closeUsage,
|
||||
loadMore: loadMoreUsage,
|
||||
} = useReportDrilldown(fetchDocumentUsage);
|
||||
|
||||
const usageRecordKey = record =>
|
||||
`${record.record_type}-${record.message?.id || record.conversation?.id}-${record.occurred_at}`;
|
||||
|
||||
const handleCopyContent = async () => {
|
||||
try {
|
||||
await copyTextToClipboard(documentContent.value);
|
||||
@@ -143,6 +183,10 @@ const handleCopyContent = async () => {
|
||||
|
||||
const handleTabChanged = tab => {
|
||||
activeTabIndex.value = tabs.value.findIndex(item => item.key === tab.key);
|
||||
|
||||
if (tab.key === TAB_KEYS.USAGE) {
|
||||
openUsage({ resourceId: documentDetails.value.id });
|
||||
}
|
||||
};
|
||||
|
||||
const fetchResponses = (page = 1) => {
|
||||
@@ -161,6 +205,8 @@ onMounted(() => {
|
||||
panelRef.value.open();
|
||||
fetchResponses();
|
||||
});
|
||||
|
||||
onUnmounted(closeUsage);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -366,6 +412,46 @@ onMounted(() => {
|
||||
/>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="activeTabKey === TAB_KEYS.USAGE"
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<div
|
||||
v-if="isUsageFetching"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="hasUsageError"
|
||||
class="rounded-lg border border-dashed border-n-weak p-4 text-sm text-n-slate-11"
|
||||
>
|
||||
{{ t('CAPTAIN.OVERVIEW.DRILLDOWN.ERROR') }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!hasUsageRecords"
|
||||
class="rounded-lg border border-dashed border-n-weak p-4 text-sm text-n-slate-11"
|
||||
>
|
||||
{{ t('CAPTAIN.DOCUMENTS.NO_USED_CONVERSATIONS') }}
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<ReportDrilldownCard
|
||||
v-for="record in usageRecords"
|
||||
:key="usageRecordKey(record)"
|
||||
:record="record"
|
||||
/>
|
||||
<Button
|
||||
v-if="hasMoreUsage"
|
||||
:label="t('CAPTAIN.OVERVIEW.DRILLDOWN.LOAD_MORE')"
|
||||
:is-loading="isUsageFetchingMore"
|
||||
slate
|
||||
outline
|
||||
class="self-center mt-2"
|
||||
@click="loadMoreUsage"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</SidePanel>
|
||||
|
||||
@@ -900,6 +900,8 @@
|
||||
"HEADER": "Documents",
|
||||
"ADD_NEW": "Create a new document",
|
||||
"FAQ_COUNT": "{n} FAQ | {n} FAQs",
|
||||
"USED_IN_CONVERSATIONS": "Used in {n} conversation | Used in {n} conversations",
|
||||
"NO_USED_CONVERSATIONS": "No conversations found for this document.",
|
||||
"SELECTED": "{count} selected",
|
||||
"SELECT_ALL": "Select all ({count})",
|
||||
"UNSELECT_ALL": "Unselect all ({count})",
|
||||
@@ -937,7 +939,8 @@
|
||||
},
|
||||
"SORT": {
|
||||
"RECENTLY_UPDATED": "Recently updated",
|
||||
"RECENTLY_CREATED": "Recently created"
|
||||
"RECENTLY_CREATED": "Recently created",
|
||||
"MOST_USED": "Most used in conversations"
|
||||
},
|
||||
"SEARCH_PLACEHOLDER": "Search..."
|
||||
},
|
||||
@@ -965,6 +968,7 @@
|
||||
"DESCRIPTION": "Review the crawled content and the FAQs generated from this source.",
|
||||
"SOURCE": "Source",
|
||||
"GENERATED_FAQS": "Generated FAQs",
|
||||
"USED_IN_CONVERSATIONS": "Used in conversations",
|
||||
"LAST_UPDATED": "Last updated",
|
||||
"NOT_AVAILABLE": "Not available",
|
||||
"CONTENT_TAB": "Crawled content",
|
||||
@@ -1168,6 +1172,7 @@
|
||||
"RESPONSES": {
|
||||
"HEADER": "FAQs",
|
||||
"ADD_NEW": "Create new FAQ",
|
||||
"NO_USED_CONVERSATIONS": "No conversations found for this FAQ.",
|
||||
"DOCUMENTABLE": {
|
||||
"CONVERSATION": "Conversation #{id}"
|
||||
},
|
||||
|
||||
@@ -22,6 +22,7 @@ import CreateResponseDialog from 'dashboard/components-next/captain/pageComponen
|
||||
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';
|
||||
import ConversationUsageDrawer from 'dashboard/components-next/captain/pageComponents/ConversationUsageDrawer.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
@@ -33,6 +34,8 @@ const responses = useMapGetter('captainResponses/getRecords');
|
||||
const isFetching = computed(() => uiFlags.value.fetchingList);
|
||||
|
||||
const selectedResponse = ref(null);
|
||||
const usageResponse = ref(null);
|
||||
const showResponseUsage = ref(false);
|
||||
const deleteDialog = ref(null);
|
||||
const bulkDeleteDialog = ref(null);
|
||||
|
||||
@@ -86,6 +89,19 @@ const handleCreateClose = () => {
|
||||
selectedResponse.value = null;
|
||||
};
|
||||
|
||||
const handleShowResponseUsage = id => {
|
||||
usageResponse.value =
|
||||
responses.value.find(response => response.id === id) || null;
|
||||
showResponseUsage.value = Boolean(usageResponse.value);
|
||||
};
|
||||
|
||||
const handleResponseUsageClose = () => {
|
||||
showResponseUsage.value = false;
|
||||
};
|
||||
|
||||
const fetchResponseUsage = ({ resourceId, ...params }) =>
|
||||
CaptainResponseAPI.getDrilldown({ responseId: resourceId, ...params });
|
||||
|
||||
const updateURLWithFilters = (page, search) => {
|
||||
const query = {
|
||||
page: page || 1,
|
||||
@@ -182,6 +198,9 @@ const fetchResponseAfterBulkAction = () => {
|
||||
const onPageChange = page => {
|
||||
const hadSelection = bulkSelectedIds.value.size > 0;
|
||||
|
||||
showResponseUsage.value = false;
|
||||
usageResponse.value = null;
|
||||
|
||||
fetchResponses(page);
|
||||
|
||||
if (hadSelection) {
|
||||
@@ -228,6 +247,8 @@ watch(
|
||||
selectedAssistantId,
|
||||
() => {
|
||||
selectedResponse.value = null;
|
||||
usageResponse.value = null;
|
||||
showResponseUsage.value = false;
|
||||
bulkSelectedIds.value = new Set();
|
||||
store.dispatch('captainResponses/setRecords', {
|
||||
records: [],
|
||||
@@ -337,6 +358,7 @@ onUnmounted(() => {
|
||||
:status="response.status"
|
||||
:created-at="response.created_at"
|
||||
:updated-at="response.updated_at"
|
||||
:used-in-conversations-count="response.used_in_conversations_count"
|
||||
:is-selected="bulkSelectedIds.has(response.id)"
|
||||
:selectable="hoveredCard === response.id || bulkSelectedIds.size > 0"
|
||||
:show-menu="!bulkSelectedIds.has(response.id)"
|
||||
@@ -345,10 +367,21 @@ onUnmounted(() => {
|
||||
@navigate="handleNavigationAction"
|
||||
@select="handleCardSelect"
|
||||
@hover="isHovered => handleCardHover(isHovered, response.id)"
|
||||
@view-conversations="handleShowResponseUsage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ConversationUsageDrawer
|
||||
:open="showResponseUsage"
|
||||
:resource-id="usageResponse?.id"
|
||||
:title="usageResponse?.question || ''"
|
||||
:conversation-count="usageResponse?.used_in_conversations_count || 0"
|
||||
:fetcher="fetchResponseUsage"
|
||||
empty-state-key="CAPTAIN.RESPONSES.NO_USED_CONVERSATIONS"
|
||||
@close="handleResponseUsageClose"
|
||||
/>
|
||||
|
||||
<DeleteDialog
|
||||
v-if="selectedResponse"
|
||||
ref="deleteDialog"
|
||||
|
||||
@@ -80,7 +80,10 @@ export function useReportDrilldown(
|
||||
|
||||
const open = async request => {
|
||||
const fingerprint = requestFingerprint(request);
|
||||
if (activeRequestFingerprint === fingerprint) return;
|
||||
if (activeRequestFingerprint === fingerprint) {
|
||||
if (hasError.value) await fetchPage(1, requestToken);
|
||||
return;
|
||||
}
|
||||
|
||||
abortActiveRequest();
|
||||
requestToken += 1;
|
||||
|
||||
Reference in New Issue
Block a user