feat: Add report bar drilldown drawer (#14626)
## Description Adds drilldown support for report bar charts powered by `ReportContainer`. Clicking a non-zero report bar now opens a right-side drawer with the conversations or messages that contributed to that bucket, with each row linking to the underlying conversation and message rows linking with `messageId`. This includes a new `GET /api/v2/accounts/:account_id/reports/drilldown` endpoint, backend drilldown builders/serializers, generic chart click emission, local drawer state via `useReportDrilldown`, compact drilldown cards, pagination, stale-response protection, and validation for unsupported drilldown dimensions. Fixes # CW-4497 https://linear.app/chatwoot/issue/CW-4497/drill-down-on-agent-conversations-report ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected) - [ ] This change requires a documentation update ## How Has This Been Tested? Ran the focused backend and frontend checks for the drilldown endpoint, builder, chart click handling, drawer/card UI, API helper, and stale-response handling. Here are the screenshots on how it looks like: <img width="1792" height="1199" alt="Screenshot 2026-06-02 at 11 32 11 PM" src="https://github.com/user-attachments/assets/6bdb8832-b9df-4bf3-9a2a-beaefe203b6e" /> <img width="1791" height="1230" alt="Screenshot 2026-06-02 at 11 32 34 PM" src="https://github.com/user-attachments/assets/36e92eb7-3208-4855-87f4-0c7f316df54d" /> <img width="1784" height="1235" alt="Screenshot 2026-06-02 at 11 32 46 PM" src="https://github.com/user-attachments/assets/f7a53916-74f2-4622-9305-042e0ac9e877" /> ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com> Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
This commit is contained in:
co-authored by
Vishnu Narayanan
Shivam Mishra
parent
6c9efc4e92
commit
6a7ca9dd3b
@@ -31,6 +31,42 @@ class ReportsAPI extends ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
getDrilldown({
|
||||
metric,
|
||||
bucketTimestamp,
|
||||
from,
|
||||
to,
|
||||
type = 'account',
|
||||
id,
|
||||
groupBy,
|
||||
businessHours,
|
||||
page,
|
||||
perPage,
|
||||
signal,
|
||||
}) {
|
||||
const requestConfig = {
|
||||
params: {
|
||||
metric,
|
||||
bucket_timestamp: bucketTimestamp,
|
||||
since: from,
|
||||
until: to,
|
||||
type,
|
||||
id,
|
||||
group_by: groupBy,
|
||||
business_hours: businessHours,
|
||||
timezone_offset: getTimeOffset(),
|
||||
page,
|
||||
per_page: perPage,
|
||||
},
|
||||
};
|
||||
|
||||
if (signal) {
|
||||
requestConfig.signal = signal;
|
||||
}
|
||||
|
||||
return axios.get(`${this.url}/drilldown`, requestConfig);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line default-param-last
|
||||
getSummary(since, until, type = 'account', id, groupBy, businessHours) {
|
||||
return axios.get(`${this.url}/summary`, {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import reportsAPI from '../reports';
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
const timezoneOffset = () => -new Date().getTimezoneOffset() / 60;
|
||||
|
||||
describe('#Reports API', () => {
|
||||
it('creates correct instance', () => {
|
||||
expect(reportsAPI).toBeInstanceOf(ApiClient);
|
||||
@@ -11,6 +13,7 @@ describe('#Reports API', () => {
|
||||
expect(reportsAPI).toHaveProperty('update');
|
||||
expect(reportsAPI).toHaveProperty('delete');
|
||||
expect(reportsAPI).toHaveProperty('getReports');
|
||||
expect(reportsAPI).toHaveProperty('getDrilldown');
|
||||
expect(reportsAPI).toHaveProperty('getSummary');
|
||||
expect(reportsAPI).toHaveProperty('getAgentReports');
|
||||
expect(reportsAPI).toHaveProperty('getLabelReports');
|
||||
@@ -42,11 +45,14 @@ describe('#Reports API', () => {
|
||||
});
|
||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports', {
|
||||
params: {
|
||||
business_hours: undefined,
|
||||
group_by: undefined,
|
||||
id: undefined,
|
||||
metric: 'conversations_count',
|
||||
since: 1621103400,
|
||||
until: 1621621800,
|
||||
type: 'account',
|
||||
timezone_offset: -0,
|
||||
timezone_offset: timezoneOffset(),
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -59,13 +65,70 @@ describe('#Reports API', () => {
|
||||
group_by: undefined,
|
||||
id: undefined,
|
||||
since: 1621103400,
|
||||
timezone_offset: -0,
|
||||
timezone_offset: timezoneOffset(),
|
||||
type: 'account',
|
||||
until: 1621621800,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('#getDrilldown', () => {
|
||||
reportsAPI.getDrilldown({
|
||||
metric: 'incoming_messages_count',
|
||||
bucketTimestamp: 1621103400,
|
||||
from: 1621103400,
|
||||
to: 1621621800,
|
||||
type: 'inbox',
|
||||
id: 1,
|
||||
groupBy: 'day',
|
||||
businessHours: false,
|
||||
page: 2,
|
||||
perPage: 25,
|
||||
});
|
||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports/drilldown', {
|
||||
params: {
|
||||
metric: 'incoming_messages_count',
|
||||
bucket_timestamp: 1621103400,
|
||||
since: 1621103400,
|
||||
until: 1621621800,
|
||||
type: 'inbox',
|
||||
id: 1,
|
||||
group_by: 'day',
|
||||
business_hours: false,
|
||||
timezone_offset: timezoneOffset(),
|
||||
page: 2,
|
||||
per_page: 25,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('#getDrilldown with abort signal', () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
reportsAPI.getDrilldown({
|
||||
metric: 'incoming_messages_count',
|
||||
bucketTimestamp: 1621103400,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports/drilldown', {
|
||||
params: {
|
||||
metric: 'incoming_messages_count',
|
||||
bucket_timestamp: 1621103400,
|
||||
since: undefined,
|
||||
until: undefined,
|
||||
type: 'account',
|
||||
id: undefined,
|
||||
group_by: undefined,
|
||||
business_hours: undefined,
|
||||
timezone_offset: timezoneOffset(),
|
||||
page: undefined,
|
||||
per_page: undefined,
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
});
|
||||
|
||||
it('#getAgentReports', () => {
|
||||
reportsAPI.getAgentReports({
|
||||
from: 1621103400,
|
||||
|
||||
@@ -121,6 +121,26 @@
|
||||
"CLEAR_FILTER": "Clear filter",
|
||||
"EMPTY_LIST": "No results found"
|
||||
},
|
||||
"DRILLDOWN": {
|
||||
"TITLE": "{metric} details",
|
||||
"RESULT_COUNT_CONVERSATION": "{count} conversation | {count} conversations",
|
||||
"RESULT_COUNT_MESSAGE": "{count} message | {count} messages",
|
||||
"EMPTY": "No records found for this bar.",
|
||||
"ERROR": "Could not load records. Please try again.",
|
||||
"ADMIN_ONLY": "Only administrators can drill down into report records.",
|
||||
"LOAD_MORE": "Load more",
|
||||
"CLOSE": "Close details",
|
||||
"PREVIOUS_BUCKET": "Previous bar",
|
||||
"NEXT_BUCKET": "Next bar",
|
||||
"UNKNOWN_CONTACT": "Unknown contact",
|
||||
"UNKNOWN_INBOX": "Unknown inbox",
|
||||
"UNASSIGNED_AGENT": "Unassigned",
|
||||
"NO_MESSAGE_CONTENT": "No message content",
|
||||
"MESSAGE_CREATED_AT": "Message created at {time}",
|
||||
"EVENT_OCCURRED_AT": "Event occurred at {time}",
|
||||
"INCOMING_MESSAGE": "Incoming message",
|
||||
"OUTGOING_MESSAGE": "Outgoing message"
|
||||
},
|
||||
"PAGINATION": {
|
||||
"RESULTS": "Showing {start} to {end} of {total} results",
|
||||
"PER_PAGE_TEMPLATE": "{size} / page"
|
||||
|
||||
@@ -101,6 +101,9 @@ export default {
|
||||
summary-fetching-key="getBotSummaryFetchingStatus"
|
||||
:group-by="groupBy"
|
||||
:report-keys="reportKeys"
|
||||
:from="from"
|
||||
:to="to"
|
||||
:business-hours="businessHours"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -121,6 +121,11 @@ export default {
|
||||
show-group-by
|
||||
@filter-change="onFilterChange"
|
||||
/>
|
||||
<ReportContainer :group-by="groupBy" />
|
||||
<ReportContainer
|
||||
:group-by="groupBy"
|
||||
:from="from"
|
||||
:to="to"
|
||||
:business-hours="businessHours"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -5,16 +5,38 @@ import { GROUP_BY_FILTER, METRIC_CHART } from './constants';
|
||||
import fromUnixTime from 'date-fns/fromUnixTime';
|
||||
import format from 'date-fns/format';
|
||||
import { formatTime } from '@chatwoot/utils';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import ChartStats from './components/ChartElements/ChartStats.vue';
|
||||
import BarChart from 'shared/components/charts/BarChart.vue';
|
||||
import ReportDrilldownDrawer from './components/ReportDrilldownDrawer.vue';
|
||||
|
||||
export default {
|
||||
components: { ChartStats, BarChart },
|
||||
components: { ChartStats, BarChart, ReportDrilldownDrawer },
|
||||
props: {
|
||||
groupBy: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
from: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
to: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
reportType: {
|
||||
type: String,
|
||||
default: 'account',
|
||||
},
|
||||
selectedItemId: {
|
||||
type: [String, Number],
|
||||
default: null,
|
||||
},
|
||||
businessHours: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
accountSummaryKey: {
|
||||
type: String,
|
||||
default: 'getAccountSummary',
|
||||
@@ -42,10 +64,27 @@ export default {
|
||||
);
|
||||
return { calculateTrend, isAverageMetricType };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
drilldownRequest: null,
|
||||
drilldownMetric: null,
|
||||
drilldownIndex: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
accountReport: 'getAccountReports',
|
||||
currentRole: 'getCurrentRole',
|
||||
}),
|
||||
isAdmin() {
|
||||
return this.currentRole === 'administrator';
|
||||
},
|
||||
canDrilldownPrev() {
|
||||
return this.findDrillableIndex(this.drilldownIndex - 1, -1) !== null;
|
||||
},
|
||||
canDrilldownNext() {
|
||||
return this.findDrillableIndex(this.drilldownIndex + 1, 1) !== null;
|
||||
},
|
||||
metrics() {
|
||||
const reportKeys = Object.keys(this.reportKeys);
|
||||
const infoText = {
|
||||
@@ -139,6 +178,82 @@ export default {
|
||||
|
||||
return options;
|
||||
},
|
||||
isDrilldownEnabled() {
|
||||
return !!(this.from && this.to);
|
||||
},
|
||||
onChartElementClick(metric, event) {
|
||||
if (!this.isDrilldownEnabled()) return;
|
||||
|
||||
const dataPoint = this.accountReport.data[metric.KEY]?.[event.dataIndex];
|
||||
if (!this.canOpenDrilldown(metric, dataPoint)) return;
|
||||
if (!this.isAdmin) {
|
||||
useAlert(this.$t('REPORT.DRILLDOWN.ADMIN_ONLY'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.openDrilldownAt(metric, event.dataIndex);
|
||||
},
|
||||
openDrilldownAt(metric, dataIndex) {
|
||||
const dataPoint = this.accountReport.data[metric.KEY]?.[dataIndex];
|
||||
if (!this.canOpenDrilldown(metric, dataPoint)) return;
|
||||
|
||||
const labels = this.getCollection(metric).labels || [];
|
||||
|
||||
this.drilldownMetric = metric;
|
||||
this.drilldownIndex = dataIndex;
|
||||
this.drilldownRequest = {
|
||||
metric: metric.KEY,
|
||||
metricName: metric.NAME,
|
||||
bucketLabel: labels[dataIndex],
|
||||
bucketTimestamp: dataPoint.timestamp,
|
||||
bucketValue: dataPoint.value,
|
||||
isAverageMetric: this.isAverageMetricType(metric.KEY),
|
||||
from: this.from,
|
||||
to: this.to,
|
||||
type: this.reportType,
|
||||
id: this.selectedItemId,
|
||||
groupBy: this.groupBy?.period,
|
||||
businessHours: this.businessHours,
|
||||
};
|
||||
},
|
||||
navigateDrilldown(direction) {
|
||||
const nextIndex = this.findDrillableIndex(
|
||||
this.drilldownIndex + direction,
|
||||
direction
|
||||
);
|
||||
if (nextIndex === null) return;
|
||||
|
||||
this.openDrilldownAt(this.drilldownMetric, nextIndex);
|
||||
},
|
||||
findDrillableIndex(startIndex, step) {
|
||||
if (!this.drilldownMetric) return null;
|
||||
|
||||
const data = this.accountReport.data[this.drilldownMetric.KEY] || [];
|
||||
for (
|
||||
let index = startIndex;
|
||||
index >= 0 && index < data.length;
|
||||
index += step
|
||||
) {
|
||||
if (this.canOpenDrilldown(this.drilldownMetric, data[index]))
|
||||
return index;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
canOpenDrilldown(metric, dataPoint) {
|
||||
if (!dataPoint) return false;
|
||||
|
||||
if (this.isAverageMetricType(metric.KEY)) {
|
||||
return dataPoint.count > 0;
|
||||
}
|
||||
|
||||
return dataPoint.value > 0;
|
||||
},
|
||||
closeDrilldown() {
|
||||
this.drilldownRequest = null;
|
||||
this.drilldownMetric = null;
|
||||
this.drilldownIndex = null;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -168,6 +283,8 @@ export default {
|
||||
v-if="accountReport.data[metric.KEY].length"
|
||||
:collection="getCollection(metric)"
|
||||
:chart-options="getChartOptions(metric)"
|
||||
:clickable="isDrilldownEnabled()"
|
||||
@element-click="onChartElementClick(metric, $event)"
|
||||
/>
|
||||
<span v-else class="text-sm text-n-slate-10">
|
||||
{{ $t('REPORT.NO_ENOUGH_DATA') }}
|
||||
@@ -176,4 +293,23 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ReportDrilldownDrawer
|
||||
:id="drilldownRequest?.id"
|
||||
:open="!!drilldownRequest"
|
||||
:metric="drilldownRequest?.metric"
|
||||
:metric-name="drilldownRequest?.metricName"
|
||||
:bucket-label="drilldownRequest?.bucketLabel"
|
||||
:bucket-timestamp="drilldownRequest?.bucketTimestamp"
|
||||
:bucket-value="drilldownRequest?.bucketValue"
|
||||
:is-average-metric="drilldownRequest?.isAverageMetric"
|
||||
:from="drilldownRequest?.from"
|
||||
:to="drilldownRequest?.to"
|
||||
:type="drilldownRequest?.type"
|
||||
:group-by="drilldownRequest?.groupBy"
|
||||
:business-hours="drilldownRequest?.businessHours"
|
||||
:can-prev="canDrilldownPrev"
|
||||
:can-next="canDrilldownNext"
|
||||
@navigate="navigateDrilldown"
|
||||
@close="closeDrilldown"
|
||||
/>
|
||||
</template>
|
||||
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { formatTime } from '@chatwoot/utils';
|
||||
import format from 'date-fns/format';
|
||||
import fromUnixTime from 'date-fns/fromUnixTime';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import { dynamicTime, shortTimestamp } from 'shared/helpers/timeHelper';
|
||||
|
||||
const props = defineProps({
|
||||
record: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
const conversation = computed(() => props.record.conversation || {});
|
||||
const message = computed(() => props.record.message || {});
|
||||
const isMessageRecord = computed(() => props.record.record_type === 'message');
|
||||
const isEventBackedConversationRecord = computed(
|
||||
() => !isMessageRecord.value && !!props.record.event_name
|
||||
);
|
||||
const conversationDisplayId = computed(() => conversation.value.display_id);
|
||||
const conversationNumber = computed(() => `#${conversationDisplayId.value}`);
|
||||
const messageDirection = computed(() => message.value.message_type);
|
||||
|
||||
const formatTimestamp = timestamp => {
|
||||
if (!timestamp) return '';
|
||||
|
||||
return format(fromUnixTime(timestamp), 'dd MMM yyyy, h:mm a');
|
||||
};
|
||||
|
||||
const compactTimestamp = timestamp => {
|
||||
if (!timestamp) return '';
|
||||
|
||||
return shortTimestamp(dynamicTime(timestamp)).trim();
|
||||
};
|
||||
|
||||
const metricValue = computed(() => {
|
||||
const value = props.record.metric_value;
|
||||
if (value === null || value === undefined) return '';
|
||||
|
||||
return formatTime(value) || `${value}`;
|
||||
});
|
||||
|
||||
const previewText = computed(() => {
|
||||
if (message.value.content) return message.value.content;
|
||||
if (conversation.value.last_message?.content) {
|
||||
return conversation.value.last_message.content;
|
||||
}
|
||||
|
||||
return t('REPORT.DRILLDOWN.NO_MESSAGE_CONTENT');
|
||||
});
|
||||
|
||||
const showPreview = computed(() => {
|
||||
return isMessageRecord.value || conversation.value.last_message;
|
||||
});
|
||||
|
||||
const messageCreatedTooltip = computed(() =>
|
||||
t('REPORT.DRILLDOWN.MESSAGE_CREATED_AT', {
|
||||
time: formatTimestamp(message.value.created_at),
|
||||
})
|
||||
);
|
||||
|
||||
const eventOccurredTooltip = computed(() =>
|
||||
t('REPORT.DRILLDOWN.EVENT_OCCURRED_AT', {
|
||||
time: formatTimestamp(props.record.occurred_at),
|
||||
})
|
||||
);
|
||||
|
||||
const directionDetails = computed(() => {
|
||||
const direction = messageDirection.value;
|
||||
if (!direction) return null;
|
||||
|
||||
const isIncoming = direction === 'incoming';
|
||||
return {
|
||||
icon: isIncoming ? 'i-lucide-arrow-down-left' : 'i-lucide-arrow-up-right',
|
||||
tooltip: isIncoming
|
||||
? t('REPORT.DRILLDOWN.INCOMING_MESSAGE')
|
||||
: t('REPORT.DRILLDOWN.OUTGOING_MESSAGE'),
|
||||
};
|
||||
});
|
||||
|
||||
const conversationPath = computed(() => {
|
||||
if (!conversationDisplayId.value) return '';
|
||||
|
||||
const path = conversationUrl({
|
||||
accountId: route.params.accountId,
|
||||
id: conversationDisplayId.value,
|
||||
});
|
||||
const params =
|
||||
isMessageRecord.value && message.value.id
|
||||
? { messageId: message.value.id }
|
||||
: null;
|
||||
|
||||
return frontendURL(path, params);
|
||||
});
|
||||
|
||||
const contactPath = computed(() => {
|
||||
if (!conversation.value.contact_id) return '';
|
||||
|
||||
return frontendURL(
|
||||
`accounts/${route.params.accountId}/contacts/${conversation.value.contact_id}`
|
||||
);
|
||||
});
|
||||
|
||||
const inboxPath = computed(() => {
|
||||
if (!conversation.value.inbox_id) return '';
|
||||
|
||||
return frontendURL(
|
||||
`accounts/${route.params.accountId}/inbox/${conversation.value.inbox_id}`
|
||||
);
|
||||
});
|
||||
|
||||
const agentPath = computed(() => {
|
||||
if (!conversation.value.assignee_id) return '';
|
||||
|
||||
return frontendURL(
|
||||
`accounts/${route.params.accountId}/reports/agents/${conversation.value.assignee_id}`
|
||||
);
|
||||
});
|
||||
|
||||
const metadataItems = computed(() => [
|
||||
{
|
||||
key: 'contact',
|
||||
icon: 'i-lucide-contact',
|
||||
label:
|
||||
conversation.value.contact_name || t('REPORT.DRILLDOWN.UNKNOWN_CONTACT'),
|
||||
path: contactPath.value,
|
||||
},
|
||||
{
|
||||
key: 'inbox',
|
||||
icon: 'i-lucide-inbox',
|
||||
label: conversation.value.inbox_name || t('REPORT.DRILLDOWN.UNKNOWN_INBOX'),
|
||||
path: inboxPath.value,
|
||||
},
|
||||
{
|
||||
key: 'agent',
|
||||
icon: 'i-lucide-user-round',
|
||||
label:
|
||||
conversation.value.assignee_name ||
|
||||
t('REPORT.DRILLDOWN.UNASSIGNED_AGENT'),
|
||||
path: agentPath.value,
|
||||
},
|
||||
]);
|
||||
|
||||
const metadataAttributes = item => {
|
||||
if (!item.path) return {};
|
||||
|
||||
return {
|
||||
href: item.path,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
};
|
||||
};
|
||||
|
||||
const metadataItemClass = item => [
|
||||
'flex min-w-0 items-center gap-1 text-n-slate-10',
|
||||
item.path ? 'group hover:text-n-blue-11 hover:underline' : '',
|
||||
];
|
||||
|
||||
const metadataIconClass = item => [
|
||||
'size-3 shrink-0 text-n-slate-9',
|
||||
item.path ? 'group-hover:text-n-blue-11' : '',
|
||||
];
|
||||
|
||||
const stopMetadataLinkClick = (event, item) => {
|
||||
if (item.path) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const openInNewTab = url => {
|
||||
if (!url) return;
|
||||
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
const openRecord = () => {
|
||||
openInNewTab(conversationPath.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
role="link"
|
||||
tabindex="0"
|
||||
class="cursor-pointer rounded-md border border-n-weak bg-n-solid-2 p-3 hover:bg-n-alpha-1 focus-visible:outline focus-visible:outline-2 focus-visible:outline-n-brand"
|
||||
@click="openRecord"
|
||||
@keydown.enter.self.prevent="openRecord"
|
||||
@keydown.space.self.prevent="openRecord"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<div
|
||||
class="flex items-center gap-2 text-sm font-medium leading-5 text-n-slate-12"
|
||||
>
|
||||
<span>{{ conversationNumber }}</span>
|
||||
<span
|
||||
v-if="conversation.status"
|
||||
class="rounded bg-n-alpha-2 px-1.5 py-0.5 text-xs capitalize text-n-slate-11"
|
||||
>
|
||||
{{ conversation.status }}
|
||||
</span>
|
||||
<span
|
||||
v-if="directionDetails"
|
||||
v-tooltip.top="directionDetails.tooltip"
|
||||
:aria-label="directionDetails.tooltip"
|
||||
class="flex size-5 items-center justify-center rounded bg-n-alpha-2 text-n-slate-11"
|
||||
>
|
||||
<Icon :icon="directionDetails.icon" class="size-3" />
|
||||
</span>
|
||||
<span
|
||||
v-if="metricValue"
|
||||
class="rounded bg-n-alpha-2 px-1.5 py-0.5 text-xs text-n-slate-11"
|
||||
>
|
||||
{{ metricValue }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="ml-2 flex shrink-0 items-center justify-end gap-1 text-right text-xs leading-4 text-n-slate-10"
|
||||
>
|
||||
<span
|
||||
v-if="isMessageRecord"
|
||||
v-tooltip.left="messageCreatedTooltip"
|
||||
:aria-label="messageCreatedTooltip"
|
||||
class="whitespace-nowrap"
|
||||
>
|
||||
{{ compactTimestamp(message.created_at) }}
|
||||
</span>
|
||||
<TimeAgo
|
||||
v-else
|
||||
:is-auto-refresh-enabled="false"
|
||||
:conversation-id="conversation.id"
|
||||
:last-activity-timestamp="conversation.last_activity_at"
|
||||
:created-at-timestamp="conversation.created_at"
|
||||
class="font-440 !text-xs !text-n-slate-10"
|
||||
/>
|
||||
<span
|
||||
v-if="isEventBackedConversationRecord"
|
||||
v-tooltip.left="eventOccurredTooltip"
|
||||
:aria-label="eventOccurredTooltip"
|
||||
class="whitespace-nowrap rounded bg-n-alpha-2 px-1 py-0.5 text-[11px] leading-4 text-n-slate-10"
|
||||
>
|
||||
{{ compactTimestamp(record.occurred_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="showPreview"
|
||||
class="mt-2 line-clamp-1 text-sm leading-5 text-n-slate-12"
|
||||
>
|
||||
{{ previewText }}
|
||||
</p>
|
||||
|
||||
<div class="mt-2 grid grid-cols-3 gap-2">
|
||||
<component
|
||||
:is="item.path ? 'a' : 'span'"
|
||||
v-for="item in metadataItems"
|
||||
:key="item.key"
|
||||
class="text-body-main"
|
||||
v-bind="metadataAttributes(item)"
|
||||
:class="metadataItemClass(item)"
|
||||
@click="stopMetadataLinkClick($event, item)"
|
||||
>
|
||||
<Icon :icon="item.icon" :class="metadataIconClass(item)" />
|
||||
<span class="truncate">{{ item.label }}</span>
|
||||
</component>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
+312
@@ -0,0 +1,312 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { formatTime } from '@chatwoot/utils';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import { useReportDrilldown } from '../composables/useReportDrilldown';
|
||||
import ReportDrilldownCard from './ReportDrilldownCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
metric: { type: String, default: '' },
|
||||
metricName: { type: String, default: '' },
|
||||
bucketLabel: { type: String, default: '' },
|
||||
bucketTimestamp: { type: Number, default: null },
|
||||
from: { type: Number, default: null },
|
||||
to: { type: Number, default: null },
|
||||
type: { type: String, default: 'account' },
|
||||
id: { type: [String, Number], default: null },
|
||||
groupBy: { type: String, default: '' },
|
||||
businessHours: { type: Boolean, default: false },
|
||||
bucketValue: { type: Number, default: null },
|
||||
isAverageMetric: { type: Boolean, default: false },
|
||||
canPrev: { type: Boolean, default: false },
|
||||
canNext: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'navigate']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const drawerRef = ref(null);
|
||||
const {
|
||||
records,
|
||||
meta,
|
||||
isFetching,
|
||||
isFetchingMore,
|
||||
hasError,
|
||||
hasRecords,
|
||||
hasMore,
|
||||
open: openDrilldown,
|
||||
close,
|
||||
loadMore,
|
||||
} = useReportDrilldown();
|
||||
|
||||
let previousActiveElement = null;
|
||||
|
||||
const isOpen = computed(() => props.open);
|
||||
|
||||
const title = computed(() => props.metricName || '');
|
||||
|
||||
const bucketValue = computed(() => {
|
||||
if (props.bucketValue === null) return '';
|
||||
|
||||
return props.isAverageMetric
|
||||
? formatTime(props.bucketValue)
|
||||
: `${props.bucketValue}`;
|
||||
});
|
||||
|
||||
// The headline stat already shows the conversation count for conversation-count
|
||||
// metrics (e.g. conversations_count), so the subtitle count would be redundant.
|
||||
const isStatConversationCount = computed(
|
||||
() =>
|
||||
!props.isAverageMetric &&
|
||||
meta.value.record_type === 'conversation' &&
|
||||
props.bucketValue === meta.value.conversation_count
|
||||
);
|
||||
|
||||
const conversationCount = computed(() => {
|
||||
if (!meta.value.conversation_count || isStatConversationCount.value)
|
||||
return '';
|
||||
|
||||
return t('REPORT.DRILLDOWN.RESULT_COUNT_CONVERSATION', {
|
||||
count: meta.value.conversation_count,
|
||||
});
|
||||
});
|
||||
|
||||
// Timing metrics (e.g. reply time) show a duration as the stat, so the underlying
|
||||
// message count adds context. Skip it when it just mirrors the conversation count
|
||||
// (e.g. first response time has one response message per conversation).
|
||||
const messageCount = computed(() => {
|
||||
if (
|
||||
!props.isAverageMetric ||
|
||||
meta.value.record_type !== 'message' ||
|
||||
!meta.value.total_count ||
|
||||
meta.value.total_count === meta.value.conversation_count
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return t('REPORT.DRILLDOWN.RESULT_COUNT_MESSAGE', {
|
||||
count: meta.value.total_count,
|
||||
});
|
||||
});
|
||||
|
||||
const subtitle = computed(() =>
|
||||
[props.bucketLabel, conversationCount.value, messageCount.value]
|
||||
.filter(Boolean)
|
||||
.join(' ⋅ ')
|
||||
);
|
||||
|
||||
const restoreFocus = () => {
|
||||
if (previousActiveElement?.isConnected) {
|
||||
previousActiveElement.focus();
|
||||
}
|
||||
previousActiveElement = null;
|
||||
};
|
||||
|
||||
const closeDrawer = () => {
|
||||
close();
|
||||
emit('close');
|
||||
restoreFocus();
|
||||
};
|
||||
|
||||
const recordKey = record =>
|
||||
`${record.record_type}-${record.message?.id || record.conversation?.id}-${
|
||||
record.occurred_at
|
||||
}`;
|
||||
|
||||
const rememberActiveElement = () => {
|
||||
if (previousActiveElement) return;
|
||||
|
||||
previousActiveElement =
|
||||
document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
};
|
||||
|
||||
const focusDrawer = () => {
|
||||
nextTick(() => drawerRef.value?.focus());
|
||||
};
|
||||
|
||||
const fetchDrilldown = () => {
|
||||
openDrilldown({
|
||||
metric: props.metric,
|
||||
bucketTimestamp: props.bucketTimestamp,
|
||||
from: props.from,
|
||||
to: props.to,
|
||||
type: props.type,
|
||||
id: props.id,
|
||||
groupBy: props.groupBy,
|
||||
businessHours: props.businessHours,
|
||||
});
|
||||
};
|
||||
|
||||
const navigate = direction => {
|
||||
if (direction < 0 && !props.canPrev) return;
|
||||
if (direction > 0 && !props.canNext) return;
|
||||
|
||||
emit('navigate', direction);
|
||||
};
|
||||
|
||||
const onKeydown = event => {
|
||||
if (!isOpen.value) return;
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
closeDrawer();
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
navigate(-1);
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
navigate(1);
|
||||
}
|
||||
};
|
||||
|
||||
useEventListener(document, 'keydown', onKeydown);
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
isDrawerOpen => {
|
||||
if (!isDrawerOpen) {
|
||||
close();
|
||||
restoreFocus();
|
||||
return;
|
||||
}
|
||||
|
||||
rememberActiveElement();
|
||||
fetchDrilldown();
|
||||
focusDrawer();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [props.metric, props.bucketTimestamp],
|
||||
() => {
|
||||
if (props.open) fetchDrilldown();
|
||||
}
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
restoreFocus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="report-drilldown-fade">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 z-50 bg-black/30"
|
||||
role="presentation"
|
||||
@click.self="closeDrawer"
|
||||
>
|
||||
<aside
|
||||
ref="drawerRef"
|
||||
class="fixed inset-y-0 right-0 flex w-full max-w-xl flex-col bg-n-solid-1 shadow-xl outline outline-1 outline-n-container"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<header
|
||||
class="flex items-start justify-between gap-4 border-b border-n-weak px-6 py-5"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<h2 class="truncate text-base font-medium text-n-slate-12">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="bucketValue"
|
||||
class="mt-1 text-xl font-semibold text-n-slate-12"
|
||||
>
|
||||
{{ bucketValue }}
|
||||
</p>
|
||||
<div
|
||||
class="text-sm text-n-slate-11"
|
||||
:class="{
|
||||
'mt-2': bucketValue,
|
||||
'mt-1': !bucketValue,
|
||||
}"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
ghost
|
||||
slate
|
||||
size="sm"
|
||||
icon="i-ph-caret-left"
|
||||
:disabled="!canPrev"
|
||||
:aria-label="$t('REPORT.DRILLDOWN.PREVIOUS_BUCKET')"
|
||||
@click="navigate(-1)"
|
||||
/>
|
||||
<Button
|
||||
ghost
|
||||
slate
|
||||
size="sm"
|
||||
icon="i-ph-caret-right"
|
||||
:disabled="!canNext"
|
||||
:aria-label="$t('REPORT.DRILLDOWN.NEXT_BUCKET')"
|
||||
@click="navigate(1)"
|
||||
/>
|
||||
<Button
|
||||
ghost
|
||||
slate
|
||||
size="sm"
|
||||
icon="i-ph-x"
|
||||
:aria-label="$t('REPORT.DRILLDOWN.CLOSE')"
|
||||
@click="closeDrawer"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto px-5 py-3">
|
||||
<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('REPORT.DRILLDOWN.ERROR') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!hasRecords"
|
||||
class="flex h-40 items-center justify-center text-sm text-n-slate-10"
|
||||
>
|
||||
{{ $t('REPORT.DRILLDOWN.EMPTY') }}
|
||||
</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('REPORT.DRILLDOWN.LOAD_MORE')"
|
||||
:is-loading="isFetchingMore"
|
||||
@click="loadMore"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -69,6 +69,9 @@ export default {
|
||||
isAgentType() {
|
||||
return this.type === 'agent';
|
||||
},
|
||||
selectedFilterId() {
|
||||
return this.selectedFilter?.id || null;
|
||||
},
|
||||
reportKeys() {
|
||||
return {
|
||||
CONVERSATIONS: 'conversations_count',
|
||||
@@ -181,5 +184,10 @@ export default {
|
||||
v-if="filterItemsList.length"
|
||||
:group-by="groupBy"
|
||||
:report-keys="reportKeys"
|
||||
:from="from"
|
||||
:to="to"
|
||||
:report-type="type"
|
||||
:selected-item-id="selectedFilterId"
|
||||
:business-hours="businessHours"
|
||||
/>
|
||||
</template>
|
||||
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import ReportDrilldownCard from '../ReportDrilldownCard.vue';
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({
|
||||
params: {
|
||||
accountId: 1,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key, params = {}) => {
|
||||
if (key === 'REPORT.DRILLDOWN.MESSAGE_CREATED_AT') {
|
||||
return `Message created at ${params.time}`;
|
||||
}
|
||||
if (key === 'REPORT.DRILLDOWN.EVENT_OCCURRED_AT') {
|
||||
return `Event occurred at ${params.time}`;
|
||||
}
|
||||
if (key === 'REPORT.DRILLDOWN.INCOMING_MESSAGE') {
|
||||
return 'Incoming message';
|
||||
}
|
||||
if (key === 'REPORT.DRILLDOWN.OUTGOING_MESSAGE') {
|
||||
return 'Outgoing message';
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('shared/helpers/timeHelper', () => ({
|
||||
dynamicTime: timestamp => {
|
||||
const timestamps = {
|
||||
1621103500: '2 minutes ago',
|
||||
1621103400: '4 days ago',
|
||||
1621103700: '4 days ago',
|
||||
};
|
||||
return timestamps[timestamp] || 'less than a minute ago';
|
||||
},
|
||||
shortTimestamp: time => {
|
||||
const timestamps = {
|
||||
'2 minutes ago': '2m',
|
||||
'4 days ago': '4d',
|
||||
};
|
||||
return timestamps[time] || 'now';
|
||||
},
|
||||
dateFormat: timestamp => `date-${timestamp}`,
|
||||
}));
|
||||
|
||||
describe('ReportDrilldownCard.vue', () => {
|
||||
const record = {
|
||||
record_type: 'message',
|
||||
conversation: {
|
||||
id: 10,
|
||||
display_id: 42,
|
||||
contact_id: 11,
|
||||
contact_name: 'Jane',
|
||||
inbox_id: 12,
|
||||
inbox_name: 'Website',
|
||||
assignee_id: 13,
|
||||
assignee_name: 'Alex',
|
||||
status: 'open',
|
||||
created_at: 1621103400,
|
||||
last_activity_at: 1621103700,
|
||||
last_message: {
|
||||
id: 100,
|
||||
content: 'Latest reply',
|
||||
message_type: 'outgoing',
|
||||
created_at: 1621103600,
|
||||
},
|
||||
},
|
||||
message: {
|
||||
id: 99,
|
||||
content: 'Need help',
|
||||
message_type: 'incoming',
|
||||
created_at: 1621103500,
|
||||
},
|
||||
metric_value: null,
|
||||
occurred_at: 1621103500,
|
||||
};
|
||||
|
||||
const mountCard = (props = {}) =>
|
||||
mount(ReportDrilldownCard, {
|
||||
props: {
|
||||
record,
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
mocks: {
|
||||
$t: key => key,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(window, 'open').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('opens the card conversation link in a new tab', async () => {
|
||||
const wrapper = mountCard();
|
||||
|
||||
expect(wrapper.text()).toContain('#42');
|
||||
expect(wrapper.text()).toContain('Need help');
|
||||
expect(wrapper.find('.i-lucide-arrow-down-left').exists()).toBe(true);
|
||||
expect(wrapper.find('[aria-label="Incoming message"]').exists()).toBe(true);
|
||||
|
||||
await wrapper.find('[role="link"]').trigger('click');
|
||||
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'/app/accounts/1/conversations/42?messageId=99',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders only message created timestamp for message rows', () => {
|
||||
const wrapper = mountCard();
|
||||
const messageCreatedLabel = wrapper
|
||||
.findAll('[aria-label]')
|
||||
.map(timestamp => timestamp.attributes('aria-label'))
|
||||
.find(label => label.includes('Message created at'));
|
||||
|
||||
expect(wrapper.text()).toContain('2m');
|
||||
expect(wrapper.text()).not.toContain('4d • 4d');
|
||||
expect(messageCreatedLabel).toContain('Message created at');
|
||||
});
|
||||
|
||||
it('renders separate contact, inbox, and agent links', async () => {
|
||||
const wrapper = mountCard();
|
||||
const links = wrapper.findAll('a');
|
||||
|
||||
expect(links.map(link => link.attributes('href'))).toEqual([
|
||||
'/app/accounts/1/contacts/11',
|
||||
'/app/accounts/1/inbox/12',
|
||||
'/app/accounts/1/reports/agents/13',
|
||||
]);
|
||||
expect(links.every(link => link.attributes('target') === '_blank')).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
links.every(link => link.classes().includes('text-n-slate-10'))
|
||||
).toBe(true);
|
||||
expect(
|
||||
links.every(link => !link.classes().includes('text-n-blue-11'))
|
||||
).toBe(true);
|
||||
expect(wrapper.find('.i-lucide-contact').exists()).toBe(true);
|
||||
expect(wrapper.find('.i-lucide-inbox').exists()).toBe(true);
|
||||
expect(wrapper.find('.i-lucide-user-round').exists()).toBe(true);
|
||||
|
||||
await links[0].trigger('click');
|
||||
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders the last message for conversation rows', () => {
|
||||
const wrapper = mountCard({
|
||||
record: {
|
||||
...record,
|
||||
record_type: 'conversation',
|
||||
message: null,
|
||||
occurred_at: 1621103500,
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain('Latest reply');
|
||||
expect(wrapper.text()).toContain('4d • 4d');
|
||||
});
|
||||
|
||||
it('renders event time alongside TimeAgo for event-backed conversation rows', () => {
|
||||
const wrapper = mountCard({
|
||||
record: {
|
||||
...record,
|
||||
record_type: 'conversation',
|
||||
message: null,
|
||||
event_name: 'conversation_bot_handoff',
|
||||
occurred_at: 1621103500,
|
||||
},
|
||||
});
|
||||
const eventOccurredLabel = wrapper
|
||||
.findAll('[aria-label]')
|
||||
.map(timestamp => timestamp.attributes('aria-label'))
|
||||
.find(label => label.includes('Event occurred at'));
|
||||
|
||||
expect(wrapper.text()).toContain('Latest reply');
|
||||
expect(wrapper.text()).toContain('4d • 4d');
|
||||
expect(wrapper.text()).toContain('2m');
|
||||
expect(eventOccurredLabel).toContain('Event occurred at');
|
||||
});
|
||||
});
|
||||
+329
@@ -0,0 +1,329 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import { nextTick } from 'vue';
|
||||
import { formatTime } from '@chatwoot/utils';
|
||||
import ReportsAPI from 'dashboard/api/reports';
|
||||
import ReportDrilldownDrawer from '../ReportDrilldownDrawer.vue';
|
||||
|
||||
vi.mock('dashboard/api/reports', () => ({
|
||||
default: {
|
||||
getDrilldown: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key, params = {}) => {
|
||||
if (key === 'REPORT.DRILLDOWN.TITLE') {
|
||||
return `${params.metric} details`;
|
||||
}
|
||||
if (key === 'REPORT.DRILLDOWN.RESULT_COUNT_CONVERSATION') {
|
||||
return `${params.count} conversations`;
|
||||
}
|
||||
if (key === 'REPORT.DRILLDOWN.RESULT_COUNT_MESSAGE') {
|
||||
return `${params.count} messages`;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ReportDrilldownDrawer.vue', () => {
|
||||
const request = {
|
||||
metric: 'incoming_messages_count',
|
||||
metricName: 'Messages received',
|
||||
bucketLabel: '20-May',
|
||||
bucketTimestamp: 1621103400,
|
||||
from: 1621103400,
|
||||
to: 1621621800,
|
||||
type: 'account',
|
||||
groupBy: 'day',
|
||||
businessHours: false,
|
||||
};
|
||||
|
||||
const payload = [
|
||||
{
|
||||
record_type: 'message',
|
||||
conversation: {
|
||||
id: 10,
|
||||
display_id: 42,
|
||||
contact_id: 11,
|
||||
contact_name: 'Jane',
|
||||
inbox_id: 12,
|
||||
inbox_name: 'Website',
|
||||
assignee_id: 13,
|
||||
assignee_name: 'Alex',
|
||||
status: 'open',
|
||||
created_at: 1621103400,
|
||||
last_activity_at: 1621103700,
|
||||
last_message: {
|
||||
id: 100,
|
||||
content: 'Latest reply',
|
||||
message_type: 'outgoing',
|
||||
created_at: 1621103600,
|
||||
},
|
||||
},
|
||||
message: {
|
||||
id: 99,
|
||||
content: 'Need help',
|
||||
message_type: 'incoming',
|
||||
created_at: 1621103500,
|
||||
},
|
||||
metric_value: null,
|
||||
occurred_at: 1621103500,
|
||||
},
|
||||
];
|
||||
|
||||
const mountDrawer = options =>
|
||||
mount(ReportDrilldownDrawer, {
|
||||
props: { open: true, ...request, ...options?.props },
|
||||
attachTo: options?.attachTo,
|
||||
global: {
|
||||
stubs: {
|
||||
Teleport: true,
|
||||
Transition: false,
|
||||
Spinner: true,
|
||||
Button: {
|
||||
props: ['label'],
|
||||
emits: ['click'],
|
||||
template:
|
||||
'<button @click="$emit(\'click\')">{{ label }}<slot /></button>',
|
||||
},
|
||||
ReportDrilldownCard: {
|
||||
props: ['record'],
|
||||
template:
|
||||
'<div data-testid="drilldown-card">#{{ record.conversation.display_id }}</div>',
|
||||
},
|
||||
},
|
||||
mocks: {
|
||||
$t: key => key,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
ReportsAPI.getDrilldown.mockResolvedValue({
|
||||
data: {
|
||||
meta: {
|
||||
total_count: 1,
|
||||
current_page: 1,
|
||||
record_type: 'message',
|
||||
conversation_count: 1,
|
||||
},
|
||||
payload,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('loads and renders drilldown cards for the request', async () => {
|
||||
const wrapper = mountDrawer();
|
||||
await flushPromises();
|
||||
|
||||
expect(ReportsAPI.getDrilldown).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metric: 'incoming_messages_count',
|
||||
bucketTimestamp: 1621103400,
|
||||
page: 1,
|
||||
})
|
||||
);
|
||||
expect(wrapper.text()).toContain('Messages received');
|
||||
expect(wrapper.text()).toContain('1 conversations');
|
||||
expect(wrapper.find('[data-testid="drilldown-card"]').text()).toBe('#42');
|
||||
});
|
||||
|
||||
it('shows the bucket aggregate value for average metrics', async () => {
|
||||
const wrapper = mountDrawer({
|
||||
props: {
|
||||
metric: 'avg_first_response_time',
|
||||
metricName: 'First response time',
|
||||
isAverageMetric: true,
|
||||
bucketValue: 2580,
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).toContain(formatTime(2580));
|
||||
});
|
||||
|
||||
it('shows both conversation and message counts when they differ (reply time)', async () => {
|
||||
ReportsAPI.getDrilldown.mockResolvedValue({
|
||||
data: {
|
||||
meta: {
|
||||
total_count: 8,
|
||||
current_page: 1,
|
||||
record_type: 'message',
|
||||
conversation_count: 5,
|
||||
},
|
||||
payload,
|
||||
},
|
||||
});
|
||||
const wrapper = mountDrawer({
|
||||
props: {
|
||||
metric: 'reply_time',
|
||||
isAverageMetric: true,
|
||||
bucketValue: 2580,
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).toContain('5 conversations');
|
||||
expect(wrapper.text()).toContain('8 messages');
|
||||
});
|
||||
|
||||
it('hides the message count when it matches the conversation count (first response time)', async () => {
|
||||
ReportsAPI.getDrilldown.mockResolvedValue({
|
||||
data: {
|
||||
meta: {
|
||||
total_count: 5,
|
||||
current_page: 1,
|
||||
record_type: 'message',
|
||||
conversation_count: 5,
|
||||
},
|
||||
payload,
|
||||
},
|
||||
});
|
||||
const wrapper = mountDrawer({
|
||||
props: {
|
||||
metric: 'avg_first_response_time',
|
||||
isAverageMetric: true,
|
||||
bucketValue: 2580,
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).toContain('5 conversations');
|
||||
expect(wrapper.text()).not.toContain('messages');
|
||||
});
|
||||
|
||||
it('shows the plain count as the bucket value for count metrics', async () => {
|
||||
const wrapper = mountDrawer({ props: { bucketValue: 128 } });
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).toContain('128');
|
||||
expect(wrapper.text()).not.toContain(formatTime(128));
|
||||
});
|
||||
|
||||
it('hides the redundant subtitle count for conversation-count metrics', async () => {
|
||||
ReportsAPI.getDrilldown.mockResolvedValue({
|
||||
data: {
|
||||
meta: {
|
||||
total_count: 5,
|
||||
current_page: 1,
|
||||
record_type: 'conversation',
|
||||
conversation_count: 5,
|
||||
},
|
||||
payload,
|
||||
},
|
||||
});
|
||||
const wrapper = mountDrawer({
|
||||
props: { metric: 'conversations_count', bucketValue: 5 },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).toContain('5');
|
||||
expect(wrapper.text()).not.toContain('conversations');
|
||||
});
|
||||
|
||||
it('keeps the subtitle count when it differs from the stat value', async () => {
|
||||
ReportsAPI.getDrilldown.mockResolvedValue({
|
||||
data: {
|
||||
meta: {
|
||||
total_count: 8,
|
||||
current_page: 1,
|
||||
record_type: 'conversation',
|
||||
conversation_count: 5,
|
||||
},
|
||||
payload,
|
||||
},
|
||||
});
|
||||
const wrapper = mountDrawer({
|
||||
props: { metric: 'resolutions_count', bucketValue: 8 },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).toContain('5 conversations');
|
||||
});
|
||||
|
||||
it('emits close when the drawer close button is clicked', async () => {
|
||||
const wrapper = mountDrawer();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.get('[aria-label="REPORT.DRILLDOWN.CLOSE"]').trigger('click');
|
||||
|
||||
expect(wrapper.emitted('close')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('emits navigate when the next button is clicked', async () => {
|
||||
const wrapper = mountDrawer({ props: { canNext: true } });
|
||||
await flushPromises();
|
||||
|
||||
await wrapper
|
||||
.get('[aria-label="REPORT.DRILLDOWN.NEXT_BUCKET"]')
|
||||
.trigger('click');
|
||||
|
||||
expect(wrapper.emitted('navigate')).toStrictEqual([[1]]);
|
||||
});
|
||||
|
||||
it('does not emit navigate past the available range', async () => {
|
||||
const wrapper = mountDrawer({ props: { canPrev: false } });
|
||||
await flushPromises();
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' }));
|
||||
|
||||
expect(wrapper.emitted('navigate')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('moves focus into the drawer when opened', async () => {
|
||||
const target = document.createElement('div');
|
||||
document.body.appendChild(target);
|
||||
const wrapper = mountDrawer({ attachTo: target });
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
expect(document.activeElement).toBe(
|
||||
wrapper.find('[role="dialog"]').element
|
||||
);
|
||||
|
||||
wrapper.unmount();
|
||||
target.remove();
|
||||
});
|
||||
|
||||
it('closes on Escape even when focus is outside the drawer', async () => {
|
||||
const target = document.createElement('div');
|
||||
document.body.appendChild(target);
|
||||
const wrapper = mountDrawer({ attachTo: target });
|
||||
await flushPromises();
|
||||
|
||||
document.body.focus();
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
|
||||
expect(wrapper.emitted('close')).toBeTruthy();
|
||||
|
||||
wrapper.unmount();
|
||||
target.remove();
|
||||
});
|
||||
|
||||
it('restores focus to the previously focused element when closed', async () => {
|
||||
const opener = document.createElement('button');
|
||||
const target = document.createElement('div');
|
||||
document.body.appendChild(opener);
|
||||
document.body.appendChild(target);
|
||||
opener.focus();
|
||||
|
||||
const wrapper = mountDrawer({ attachTo: target });
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
await wrapper.get('[aria-label="REPORT.DRILLDOWN.CLOSE"]').trigger('click');
|
||||
|
||||
expect(document.activeElement).toBe(opener);
|
||||
|
||||
wrapper.unmount();
|
||||
target.remove();
|
||||
opener.remove();
|
||||
});
|
||||
});
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import ReportsAPI from 'dashboard/api/reports';
|
||||
import { useReportDrilldown } from '../useReportDrilldown';
|
||||
|
||||
vi.mock('dashboard/api/reports', () => ({
|
||||
default: {
|
||||
getDrilldown: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const deferredPromise = () => {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
const drilldownRequest = overrides => ({
|
||||
metric: 'conversations_count',
|
||||
bucketTimestamp: 1,
|
||||
from: 1621103400,
|
||||
to: 1621621800,
|
||||
type: 'account',
|
||||
groupBy: 'day',
|
||||
businessHours: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('useReportDrilldown', () => {
|
||||
const mountComposable = () =>
|
||||
mount({
|
||||
setup() {
|
||||
return useReportDrilldown();
|
||||
},
|
||||
template: '<div />',
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('does not request drilldown again for an identical active request', async () => {
|
||||
const request = deferredPromise();
|
||||
ReportsAPI.getDrilldown.mockReturnValue(request.promise);
|
||||
|
||||
const wrapper = mountComposable();
|
||||
wrapper.vm.open(drilldownRequest());
|
||||
wrapper.vm.open(drilldownRequest());
|
||||
|
||||
expect(ReportsAPI.getDrilldown).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('aborts an in-flight request when a newer request is opened', async () => {
|
||||
const firstRequest = deferredPromise();
|
||||
const secondRequest = deferredPromise();
|
||||
let firstSignal;
|
||||
|
||||
ReportsAPI.getDrilldown
|
||||
.mockImplementationOnce(({ signal }) => {
|
||||
firstSignal = signal;
|
||||
return firstRequest.promise;
|
||||
})
|
||||
.mockReturnValueOnce(secondRequest.promise);
|
||||
|
||||
const wrapper = mountComposable();
|
||||
wrapper.vm.open(drilldownRequest({ bucketTimestamp: 1 }));
|
||||
wrapper.vm.open(drilldownRequest({ bucketTimestamp: 2 }));
|
||||
|
||||
expect(firstSignal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('passes an abort signal to drilldown requests', async () => {
|
||||
const request = deferredPromise();
|
||||
ReportsAPI.getDrilldown.mockReturnValue(request.promise);
|
||||
|
||||
const wrapper = mountComposable();
|
||||
wrapper.vm.open(drilldownRequest());
|
||||
|
||||
expect(ReportsAPI.getDrilldown).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
page: 1,
|
||||
signal: expect.any(AbortSignal),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores stale responses when a newer request is opened first', async () => {
|
||||
const firstRequest = deferredPromise();
|
||||
const secondRequest = deferredPromise();
|
||||
ReportsAPI.getDrilldown
|
||||
.mockReturnValueOnce(firstRequest.promise)
|
||||
.mockReturnValueOnce(secondRequest.promise);
|
||||
|
||||
const wrapper = mountComposable();
|
||||
wrapper.vm.open(drilldownRequest({ bucketTimestamp: 1 }));
|
||||
wrapper.vm.open(drilldownRequest({ bucketTimestamp: 2 }));
|
||||
|
||||
secondRequest.resolve({
|
||||
data: {
|
||||
meta: { current_page: 1, total_count: 1 },
|
||||
payload: [{ id: 'second' }],
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.vm.records).toEqual([{ id: 'second' }]);
|
||||
expect(wrapper.vm.meta).toEqual({ current_page: 1, total_count: 1 });
|
||||
|
||||
firstRequest.resolve({
|
||||
data: {
|
||||
meta: { current_page: 1, total_count: 1 },
|
||||
payload: [{ id: 'first' }],
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.vm.records).toEqual([{ id: 'second' }]);
|
||||
expect(wrapper.vm.meta).toEqual({ current_page: 1, total_count: 1 });
|
||||
});
|
||||
});
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import ReportsAPI from 'dashboard/api/reports';
|
||||
|
||||
export function useReportDrilldown() {
|
||||
const activeRequest = ref(null);
|
||||
const records = ref([]);
|
||||
const meta = ref({});
|
||||
const isFetching = ref(false);
|
||||
const isFetchingMore = ref(false);
|
||||
const hasError = ref(false);
|
||||
let requestToken = 0;
|
||||
let activeRequestController = null;
|
||||
let activeRequestFingerprint = null;
|
||||
|
||||
const hasRecords = computed(() => records.value.length > 0);
|
||||
const hasMore = computed(() => {
|
||||
return records.value.length < (meta.value.total_count || 0);
|
||||
});
|
||||
|
||||
const isCurrentRequest = token =>
|
||||
token === requestToken && !!activeRequest.value;
|
||||
|
||||
const requestFingerprint = request =>
|
||||
JSON.stringify({
|
||||
metric: request.metric,
|
||||
bucketTimestamp: request.bucketTimestamp,
|
||||
from: request.from,
|
||||
to: request.to,
|
||||
type: request.type,
|
||||
id: request.id,
|
||||
groupBy: request.groupBy,
|
||||
businessHours: request.businessHours,
|
||||
});
|
||||
|
||||
const abortActiveRequest = () => {
|
||||
if (!activeRequestController) return;
|
||||
|
||||
activeRequestController.abort();
|
||||
activeRequestController = null;
|
||||
};
|
||||
|
||||
const isAbortError = error =>
|
||||
error?.name === 'AbortError' ||
|
||||
error?.name === 'CanceledError' ||
|
||||
error?.code === 'ERR_CANCELED';
|
||||
|
||||
const fetchPage = async (page, token = requestToken) => {
|
||||
if (!activeRequest.value) return;
|
||||
|
||||
const request = activeRequest.value;
|
||||
const controller = new AbortController();
|
||||
const loadingState = page === 1 ? isFetching : isFetchingMore;
|
||||
activeRequestController = controller;
|
||||
loadingState.value = true;
|
||||
hasError.value = false;
|
||||
|
||||
try {
|
||||
const response = await ReportsAPI.getDrilldown({
|
||||
...request,
|
||||
page,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!isCurrentRequest(token)) return;
|
||||
|
||||
meta.value = response.data.meta || {};
|
||||
records.value =
|
||||
page === 1
|
||||
? response.data.payload || []
|
||||
: [...records.value, ...(response.data.payload || [])];
|
||||
} catch (error) {
|
||||
if (!isCurrentRequest(token) || isAbortError(error)) return;
|
||||
|
||||
hasError.value = true;
|
||||
} finally {
|
||||
if (activeRequestController === controller) {
|
||||
activeRequestController = null;
|
||||
}
|
||||
|
||||
if (isCurrentRequest(token)) {
|
||||
loadingState.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const open = async request => {
|
||||
const fingerprint = requestFingerprint(request);
|
||||
if (activeRequestFingerprint === fingerprint) return;
|
||||
|
||||
abortActiveRequest();
|
||||
requestToken += 1;
|
||||
activeRequestFingerprint = fingerprint;
|
||||
activeRequest.value = request;
|
||||
records.value = [];
|
||||
meta.value = {};
|
||||
hasError.value = false;
|
||||
isFetchingMore.value = false;
|
||||
await fetchPage(1, requestToken);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
abortActiveRequest();
|
||||
requestToken += 1;
|
||||
activeRequestFingerprint = null;
|
||||
activeRequest.value = null;
|
||||
records.value = [];
|
||||
meta.value = {};
|
||||
hasError.value = false;
|
||||
isFetching.value = false;
|
||||
isFetchingMore.value = false;
|
||||
};
|
||||
|
||||
const loadMore = () => {
|
||||
if (
|
||||
!activeRequest.value ||
|
||||
!hasMore.value ||
|
||||
isFetching.value ||
|
||||
isFetchingMore.value
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetchPage((meta.value.current_page || 1) + 1, requestToken);
|
||||
};
|
||||
|
||||
return {
|
||||
activeRequest,
|
||||
records,
|
||||
meta,
|
||||
isFetching,
|
||||
isFetchingMore,
|
||||
hasError,
|
||||
hasRecords,
|
||||
hasMore,
|
||||
open,
|
||||
close,
|
||||
loadMore,
|
||||
};
|
||||
}
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import ReportContainer from '../ReportContainer.vue';
|
||||
|
||||
vi.mock('dashboard/composables', () => ({
|
||||
useAlert: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables/useReportMetrics', () => ({
|
||||
useReportMetrics: () => ({
|
||||
calculateTrend: () => 0,
|
||||
isAverageMetricType: key =>
|
||||
['avg_first_response_time', 'avg_resolution_time', 'reply_time'].includes(
|
||||
key
|
||||
),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ReportContainer.vue', () => {
|
||||
const mountComponent = ({
|
||||
dataPoint = { value: 2, timestamp: 1621103400 },
|
||||
data,
|
||||
reportKey = 'conversations_count',
|
||||
role = 'administrator',
|
||||
} = {}) =>
|
||||
shallowMount(ReportContainer, {
|
||||
props: {
|
||||
from: 1621103400,
|
||||
to: 1621621800,
|
||||
groupBy: { period: 'day' },
|
||||
reportType: 'inbox',
|
||||
selectedItemId: 1,
|
||||
businessHours: true,
|
||||
reportKeys: {
|
||||
CONVERSATIONS: reportKey,
|
||||
},
|
||||
},
|
||||
global: {
|
||||
mocks: {
|
||||
$t: key => key,
|
||||
$store: {
|
||||
getters: {
|
||||
getAccountReports: {
|
||||
isFetching: {
|
||||
[reportKey]: false,
|
||||
},
|
||||
data: {
|
||||
[reportKey]: data || [dataPoint],
|
||||
},
|
||||
},
|
||||
getCurrentRole: role,
|
||||
},
|
||||
},
|
||||
},
|
||||
stubs: {
|
||||
ChartStats: true,
|
||||
ReportDrilldownDrawer: {
|
||||
name: 'ReportDrilldownDrawer',
|
||||
props: [
|
||||
'open',
|
||||
'metric',
|
||||
'metricName',
|
||||
'bucketLabel',
|
||||
'bucketTimestamp',
|
||||
'bucketValue',
|
||||
'isAverageMetric',
|
||||
'from',
|
||||
'to',
|
||||
'type',
|
||||
'id',
|
||||
'groupBy',
|
||||
'businessHours',
|
||||
'canPrev',
|
||||
'canNext',
|
||||
],
|
||||
emits: ['navigate', 'close'],
|
||||
template: '<div />',
|
||||
},
|
||||
BarChart: {
|
||||
name: 'BarChart',
|
||||
props: ['collection', 'chartOptions', 'clickable'],
|
||||
emits: ['elementClick'],
|
||||
template:
|
||||
'<button data-test-id="bar-chart" @click="$emit(\'elementClick\', { dataIndex: 0, label: \'20-May\', value: 2 })" />',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('opens a drilldown request with report context when a non-zero bar is clicked', async () => {
|
||||
const wrapper = mountComponent();
|
||||
|
||||
await wrapper.find('[data-test-id="bar-chart"]').trigger('click');
|
||||
|
||||
const drawer = wrapper.findComponent({ name: 'ReportDrilldownDrawer' });
|
||||
expect(drawer.props('open')).toBe(true);
|
||||
expect(drawer.props()).toMatchObject({
|
||||
metric: 'conversations_count',
|
||||
metricName: 'REPORT.METRICS.CONVERSATIONS.NAME',
|
||||
bucketLabel: '15-May',
|
||||
bucketTimestamp: 1621103400,
|
||||
from: 1621103400,
|
||||
to: 1621621800,
|
||||
type: 'inbox',
|
||||
id: 1,
|
||||
groupBy: 'day',
|
||||
businessHours: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows an alert and does not open drilldown for non-admin users', async () => {
|
||||
const wrapper = mountComponent({ role: 'agent' });
|
||||
|
||||
await wrapper.find('[data-test-id="bar-chart"]').trigger('click');
|
||||
|
||||
expect(useAlert).toHaveBeenCalledWith('REPORT.DRILLDOWN.ADMIN_ONLY');
|
||||
expect(
|
||||
wrapper.findComponent({ name: 'ReportDrilldownDrawer' }).props('open')
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not open drilldown for zero-value count bars', async () => {
|
||||
const wrapper = mountComponent({
|
||||
dataPoint: { value: 0, timestamp: 1621103400 },
|
||||
});
|
||||
|
||||
await wrapper.find('[data-test-id="bar-chart"]').trigger('click');
|
||||
|
||||
expect(
|
||||
wrapper.findComponent({ name: 'ReportDrilldownDrawer' }).props('open')
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('opens average metric drilldown when the bucket has contributing records', async () => {
|
||||
const wrapper = mountComponent({
|
||||
reportKey: 'avg_first_response_time',
|
||||
dataPoint: { value: 90, count: 2, timestamp: 1621103400 },
|
||||
});
|
||||
|
||||
await wrapper.find('[data-test-id="bar-chart"]').trigger('click');
|
||||
|
||||
const drawer = wrapper.findComponent({ name: 'ReportDrilldownDrawer' });
|
||||
expect(drawer.props('open')).toBe(true);
|
||||
expect(drawer.props()).toMatchObject({
|
||||
metric: 'avg_first_response_time',
|
||||
bucketTimestamp: 1621103400,
|
||||
});
|
||||
});
|
||||
|
||||
it('navigates to adjacent drillable buckets within the report range', async () => {
|
||||
const wrapper = mountComponent({
|
||||
data: [
|
||||
{ value: 2, timestamp: 1621103400 },
|
||||
{ value: 0, timestamp: 1621189800 },
|
||||
{ value: 5, timestamp: 1621276200 },
|
||||
],
|
||||
});
|
||||
|
||||
await wrapper.find('[data-test-id="bar-chart"]').trigger('click');
|
||||
|
||||
const drawer = wrapper.findComponent({ name: 'ReportDrilldownDrawer' });
|
||||
// Opened on the first bucket: no previous, but a later drillable bucket exists.
|
||||
expect(drawer.props('bucketTimestamp')).toBe(1621103400);
|
||||
expect(drawer.props('canPrev')).toBe(false);
|
||||
expect(drawer.props('canNext')).toBe(true);
|
||||
|
||||
// Skips the zero-value middle bucket and lands on the last drillable one.
|
||||
drawer.vm.$emit('navigate', 1);
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(drawer.props('bucketTimestamp')).toBe(1621276200);
|
||||
expect(drawer.props('canPrev')).toBe(true);
|
||||
expect(drawer.props('canNext')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -19,8 +19,14 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
clickable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['elementClick']);
|
||||
|
||||
ChartJS.register(Title, Tooltip, BarElement, CategoryScale, LinearScale);
|
||||
|
||||
const fontFamily =
|
||||
@@ -67,8 +73,39 @@ const defaultChartOptions = {
|
||||
},
|
||||
};
|
||||
|
||||
const handleClick = (event, elements, chart) => {
|
||||
props.chartOptions.onClick?.(event, elements, chart);
|
||||
|
||||
if (!props.clickable || !elements.length) return;
|
||||
|
||||
const { datasetIndex, index } = elements[0];
|
||||
const dataset = props.collection.datasets?.[datasetIndex] || {};
|
||||
|
||||
emit('elementClick', {
|
||||
datasetIndex,
|
||||
dataIndex: index,
|
||||
dataset,
|
||||
label: props.collection.labels?.[index],
|
||||
value: dataset.data?.[index],
|
||||
});
|
||||
};
|
||||
|
||||
const handleHover = (event, elements, chart) => {
|
||||
props.chartOptions.onHover?.(event, elements, chart);
|
||||
|
||||
if (!event?.native?.target) return;
|
||||
|
||||
event.native.target.style.cursor =
|
||||
props.clickable && elements.length ? 'pointer' : 'default';
|
||||
};
|
||||
|
||||
const options = computed(() => {
|
||||
return { ...defaultChartOptions, ...props.chartOptions };
|
||||
return {
|
||||
...defaultChartOptions,
|
||||
...props.chartOptions,
|
||||
onClick: handleClick,
|
||||
onHover: handleHover,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import BarChart from '../charts/BarChart.vue';
|
||||
|
||||
vi.mock('vue-chartjs', () => ({
|
||||
Bar: {
|
||||
name: 'Bar',
|
||||
props: ['data', 'options'],
|
||||
template: '<canvas />',
|
||||
},
|
||||
}));
|
||||
|
||||
describe('BarChart.vue', () => {
|
||||
it('emits the clicked chart element when clickable', () => {
|
||||
const wrapper = shallowMount(BarChart, {
|
||||
props: {
|
||||
clickable: true,
|
||||
collection: {
|
||||
labels: ['20-May'],
|
||||
datasets: [{ type: 'bar', data: [3] }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const options = wrapper.findComponent({ name: 'Bar' }).props('options');
|
||||
options.onClick({}, [{ datasetIndex: 0, index: 0 }], {});
|
||||
|
||||
expect(wrapper.emitted('elementClick')[0][0]).toEqual({
|
||||
datasetIndex: 0,
|
||||
dataIndex: 0,
|
||||
dataset: { type: 'bar', data: [3] },
|
||||
label: '20-May',
|
||||
value: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not emit when chart is not clickable', () => {
|
||||
const wrapper = shallowMount(BarChart, {
|
||||
props: {
|
||||
clickable: false,
|
||||
collection: {
|
||||
labels: ['20-May'],
|
||||
datasets: [{ type: 'bar', data: [3] }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const options = wrapper.findComponent({ name: 'Bar' }).props('options');
|
||||
options.onClick({}, [{ datasetIndex: 0, index: 0 }], {});
|
||||
|
||||
expect(wrapper.emitted('elementClick')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user